Files
swarm-assistent/Assets/assistent.js
T

9392 lines
362 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
* v0.8.0: Split assets — SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
*/
(function () {
const LS_BASE = 'swarm_assistent_base_url';
const LS_MODEL = 'swarm_assistent_model';
const LS_EMBED = 'swarm_assistent_embed_model';
const LS_PACK = 'swarm_assistent_pack';
const LS_PACK_ORDINARY_MIG = 'swarm_assistent_pack_ordinary_v1';
/** One-shot: drop junior chat tags stuck in LS so UI/warm pick preferred/senior. */
const LS_MODEL_SENIOR_MIG = 'swarm_assistent_model_senior_v1';
const LS_PERSONA = 'swarm_assistent_persona';
const LS_VIEW = 'swarm_assistent_view';
const LS_AUTO_VISION = 'swarm_assistent_auto_vision';
const LS_AUTO_APPLY = 'swarm_assistent_auto_apply';
const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate';
const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique';
const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download';
/** When '1', unload chat LLM before Generate (frees VRAM; VL reload can take 12 min). Default off. */
const LS_PARK_LLM = 'swarm_assistent_park_llm';
const LS_PANE_WIDTH = 'swarm_assistent_pane_width';
const LS_WELCOMED = 'swarm_assistent_welcomed';
const LS_TASTE = 'swarm_assistent_taste';
const LS_HISTORY = 'swarm_assistent_history';
const LS_CHATS = 'swarm_assistent_chats_v1';
const LS_BOARD_TAB = 'swarm_assistent_board_tab';
const MAX_CHATS = 40;
const MAX_CHAT_MSGS = 24;
const TAB_BUTTON_ID = 'maintab_assistent';
const GEN_ID = 'generate';
let MAX_REF_SLOTS = 4;
let MAX_GEN_VARIANTS = 4;
let CONTEXT_PROMPT_MAX = 2000;
let HISTORY_KEEP_TURNS = 4;
let INVENTORY_PROMPT_RICH = 12;
let INVENTORY_PROMPT_NAMES = 24;
let ASPECT_TABLE = {
'1:1': [1024, 1024],
'4:3': [1184, 896],
'3:2': [1248, 832],
'16:9': [1376, 768],
'2.35:1': [1568, 672],
'4:5': [928, 1152],
'2:3': [832, 1248],
'9:16': [768, 1376],
};
let PACK_ALIASES = {
ordinary: 'ordinary',
combine: 'ordinary',
normal: 'ordinary',
general: 'ordinary',
default: 'ordinary',
write: 'write_prompt',
write_prompt: 'write_prompt',
critique: 'critique_image',
critique_image: 'critique_image',
compose: 'compose_scene',
compose_scene: 'compose_scene',
params: 'fix_params',
fix_params: 'fix_params',
inpaint: 'inpaint_edit',
inpaint_edit: 'inpaint_edit',
describe: 'describe_ref',
describe_ref: 'describe_ref',
card: 'catalog_card',
catalog: 'catalog_card',
catalog_card: 'catalog_card',
};
let WELCOME_HTML = `
<div class="sa-welcome-title">Assistent · Krea 2</div>
<ul>
<li><strong>Generate</strong> слева — живой просмотр. В чат сам не уходит.</li>
<li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li>
<li>Галочка vision на окне — отправить кадр модели.</li>
<li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li>
<li>Кнопки патча только у последнего предложения.</li>
</ul>
Напиши, что сгенерировать — или кинь референс и попроси правку.`;
let HELP_TEXT = `Slash-команды (без LLM):
/help — этот список
/new — новый чат (текущий сохранится в Историю)
/history — открыть список чатов
/debug — сводка UI/Exact (без LLM)
/debug ask — то же + короткий ответ модели
/why — сразу /debug ask
/gen — Generate сейчас
/look generate|refN — прикрепить окно к vision
/init /mask /clear — Init / Mask / Clear Init
/interrupt — остановить генерацию
/aspect 16:9 — размер из таблицы 1K
/seed lock|random — зафиксировать или рандомизировать seed
/vary — новый seed, тот же промпт
/pack write|critique|compose|params|inpaint|describe|card
/civitai <query> — поиск LoRA (Confirm в чате)
/inventory — rescan моделей + обновить список LoRA
Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.
При старте всегда новый чат; смена чата в Истории восстанавливает параметры.`;
let SLASH_COMMANDS = [
{ cmd: '/help', hint: 'список команд' },
{ cmd: '/new', hint: 'новый чат' },
{ cmd: '/history', hint: 'история чатов' },
{ cmd: '/debug', hint: 'сводка · ask = с LLM' },
{ cmd: '/why', hint: 'debug + пояснение LLM' },
{ cmd: '/gen', hint: 'Generate сейчас' },
{ cmd: '/look ', hint: 'generate|refN' },
{ cmd: '/init', hint: 'как Init' },
{ cmd: '/mask', hint: 'как Mask' },
{ cmd: '/clear', hint: 'сброс Init/Mask' },
{ cmd: '/interrupt', hint: 'стоп' },
{ cmd: '/aspect ', hint: '16:9' },
{ cmd: '/seed ', hint: 'lock|random' },
{ cmd: '/vary', hint: 'новый seed' },
{ cmd: '/pack ', hint: 'write|critique|…' },
{ cmd: '/civitai ', hint: 'запрос LoRA' },
{ cmd: '/inventory', hint: 'rescan моделей' },
];
const state = {
history: [],
packsLoaded: false,
config: null,
exact: null,
sessionExact: {},
lastUserParamIntent: false,
lastUserControlIntent: false,
lastPatch: null,
pendingSilentGen: false,
pendingPromptEnMerge: null,
enabledSkills: [],
kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } },
preferredEmbed: null,
busy: false,
generating: false,
chatEpoch: 0,
waitImageTimer: null,
lastImageDataUrl: null,
preferredModel: null,
dragDepth: 0,
inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
inventoryFetchedAt: 0,
taste: { styles: [], likes: [], avoid: [], notes: '', updated: 0 },
tasteSaveTimer: null,
streamEl: null,
streamMeta: null,
streamText: '',
streamFenceDone: false,
critiqueHopUsed: false,
visionHopUsed: false,
lastSystemChars: 0,
lastSystemLayers: null,
lastContextChars: 0,
busyPhase: 'idle',
busyStarted: 0,
gotDelta: false,
busyTimer: null,
slots: [],
selectedSlotId: 'ref1',
refSeq: 1,
genResults: [],
selectedGenResultId: null,
lightboxIndex: -1,
packUserTouched: false,
view: 'chat',
boardTab: 'generate',
personas: [],
modelCards: {},
cardsSelection: null,
cardsBusy: false,
pendingPersonaNote: null,
chats: [],
activeChatId: null,
restoringChat: false,
chatsPanelOpen: false,
chatsQuery: '',
chatsSearchHits: null,
slashIndex: 0,
llmParked: false,
expectColdLoad: false,
memoryRows: [],
userPrefs: [],
settingsTab: 'behavior',
settingsPersonaId: null,
wanted: { count: 0, items: [] },
wantedKeys: new Set(),
ollamaHealth: 'unknown',
};
/** Disk persistence module (assistent.persist.js) — absent means localStorage only. */
function diskPersist() {
return (window.SA && window.SA.persist) || null;
}
function $(id) {
return document.getElementById(id);
}
function modelShort(name) {
const s = String(name || '');
const slash = s.lastIndexOf('/');
return (slash >= 0 ? s.slice(slash + 1) : s) || 'model';
}
function fmtElapsed(ms) {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) {
return `${s}s`;
}
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
}
function hideChatEmpty() {
const empty = $('sa_chat_empty');
if (empty) {
empty.hidden = true;
}
}
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');
if (!box || !empty) {
return;
}
const hasMsg = [...box.children].some((el) => el.id !== 'sa_chat_empty');
empty.hidden = hasMsg;
}
function setBusyPhase(phase) {
state.busyPhase = phase || 'thinking';
tickBusyUi();
syncPatchActionAvailability();
syncGenerateBusy();
}
function tickBusyUi() {
if (state.busyPhase === 'idle') {
return;
}
const elapsed = Date.now() - (state.busyStarted || Date.now());
// Only claim "Loading into GPU" when the model was parked / known cold.
// Otherwise a slow first token on an already-resident VL looks like a 2min reload.
if (!state.gotDelta && (state.busyPhase === 'thinking' || state.busyPhase === 'waiting') && elapsed > 1600) {
state.busyPhase = state.llmParked || state.expectColdLoad ? 'loading' : 'waiting';
}
const model = modelShort($('sa_model')?.value);
const labels = {
encoding: 'Encoding image…',
waiting: 'Жду Ollama / первый токен…',
loading: `Загружаю ${model} в GPU… обычно 30–120 с после park`,
warming: `Возвращаю ${model} в GPU…`,
thinking: 'Thinking…',
streaming: 'Writing…',
generating: 'Generating image…',
parking: 'Освобождаю VRAM (park LLM)…',
applying: 'Applying patch…',
silent_gen: 'Применяю патч → Generate…',
refining: 'Civitai search done — refining…',
};
const text = labels[state.busyPhase] || 'Working…';
const barText = $('sa_livebar_text');
if (barText) {
barText.textContent = text;
}
const elapsedEl = $('sa_elapsed');
if (elapsedEl) {
elapsedEl.textContent = fmtElapsed(elapsed);
}
const status = $('sa_status');
if (status) {
status.textContent = text;
status.classList.add('sa-status-busy');
}
}
function startBusyUi(phase) {
state.busyStarted = Date.now();
state.gotDelta = false;
state.busyPhase = phase || 'thinking';
$('swarm_assistent_root')?.classList.add('sa-is-busy');
$('sa_composer')?.classList.add('sa-composer-busy');
const send = $('sa_btn_send');
if (send) {
send.disabled = true;
}
const input = $('sa_input');
if (input) {
input.classList.add('sa-input-busy');
}
const bar = $('sa_livebar');
if (bar) {
bar.hidden = false;
}
const dot = $('sa_live_dot');
if (dot) {
dot.hidden = false;
}
tickBusyUi();
syncPatchActionAvailability();
syncGenerateBusy();
if (state.busyTimer) {
clearInterval(state.busyTimer);
}
state.busyTimer = setInterval(tickBusyUi, 400);
}
function stopBusyUi(finalStatus) {
if (state.busyTimer) {
clearInterval(state.busyTimer);
state.busyTimer = null;
}
const elapsed = Date.now() - (state.busyStarted || Date.now());
state.busyPhase = 'idle';
$('swarm_assistent_root')?.classList.remove('sa-is-busy');
$('sa_composer')?.classList.remove('sa-composer-busy');
const send = $('sa_btn_send');
if (send) {
send.disabled = false;
}
const input = $('sa_input');
if (input) {
input.classList.remove('sa-input-busy');
}
const bar = $('sa_livebar');
if (bar) {
bar.hidden = true;
}
const dot = $('sa_live_dot');
if (dot) {
dot.hidden = true;
}
const status = $('sa_status');
if (status) {
status.classList.remove('sa-status-busy');
}
if (finalStatus != null) {
const suffix = elapsed >= 1000 ? ` · ${fmtElapsed(elapsed)}` : '';
setStatus(finalStatus + suffix);
}
syncPatchActionAvailability();
syncGenerateBusy();
}
function setStatus(text) {
const el = $('sa_status');
if (el) {
el.textContent = text || '';
}
}
function setInterruptVisible(on) {
const btn = $('sa_btn_interrupt');
if (btn) {
btn.hidden = !on;
btn.classList.toggle('sa-interrupt-active', !!on);
}
}
function looksLikeKrea(text) {
const s = String(text || '');
return /krea\s*2|krea2|krea-2/i.test(s) || /krea/i.test(s);
}
function resolveCurrentCheckpoint() {
const out = {
name: null,
architecture: null,
compat_class: null,
title: null,
class: null,
source: null,
};
try {
if (typeof currentModelHelper !== 'undefined' && currentModelHelper) {
out.name = currentModelHelper.curModel || null;
out.architecture = currentModelHelper.curArch || null;
out.compat_class = currentModelHelper.curCompatClass || null;
out.source = 'currentModelHelper';
}
} catch (e) { /* ignore */ }
try {
if (typeof getCurrentModel === 'function') {
const model = getCurrentModel();
if (model) {
out.name = out.name || model.name || null;
out.title = model.title || null;
out.architecture = out.architecture || model.architecture || null;
out.class = model.class || null;
out.compat_class = out.compat_class || model.compat_class || null;
out.source = out.source || 'getCurrentModel';
}
}
} catch (e) { /* ignore */ }
try {
const sel =
document.getElementById('current_model') ||
document.getElementById('input_model');
if (sel) {
const opt = sel.selectedOptions && sel.selectedOptions[0];
const hint = [
sel.value,
opt && opt.text,
opt && opt.dataset && opt.dataset.cleanname,
]
.filter(Boolean)
.join(' ');
if (!out.name && sel.value) {
out.name = sel.value;
out.source = out.source || 'dropdown';
}
if (hint && !out.architecture) {
out.title = out.title || hint;
}
}
} catch (e) { /* ignore */ }
return out;
}
function isKreaSelected() {
try {
const m = resolveCurrentCheckpoint();
const blob = [
m.architecture,
m.compat_class,
m.title,
m.name,
m.class,
].join(' ');
return looksLikeKrea(blob);
} catch (e) {
return false;
}
}
function updateGate() {
const ok = isKreaSelected();
const gate = $('sa_gate');
const layout = $('sa_layout');
if (gate) {
gate.hidden = ok;
if (!ok) {
const m = resolveCurrentCheckpoint();
const seen = [m.architecture, m.compat_class, m.name]
.filter(Boolean)
.join(' · ');
const p = gate.querySelector('p');
if (p) {
p.innerHTML = seen
? `Swarm Assistent is for <strong>Krea 2</strong> models only. Current: <code>${escapeHtml(
seen,
)}</code> — pick a checkpoint with architecture <code>krea-2</code>.`
: 'Swarm Assistent is for <strong>Krea 2</strong> models only. Select a Krea 2 checkpoint on Generate to enable the chat.';
}
}
}
if (layout) {
layout.classList.toggle('sa-disabled', !ok);
}
return ok;
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** Known ### headings → RU display labels. JSON Patch is hidden (real UI is the patch strip). */
const PROSE_SECTION_TITLES = {
critique: 'Критика',
критика: 'Критика',
analysis: 'Разбор',
разбор: 'Разбор',
notes: 'Заметки',
заметки: 'Заметки',
summary: 'Кратко',
кратко: 'Кратко',
prompt: 'Промпт',
промпт: 'Промпт',
'improved prompt': 'Промпт',
'next prompt': 'Промпт',
deliverable: 'Итог',
итог: 'Итог',
verdict: 'Вердикт',
вердикт: 'Вердикт',
issues: 'Проблемы',
проблемы: 'Проблемы',
fixes: 'Правки',
правки: 'Правки',
suggestion: 'Предложение',
suggestions: 'Предложения',
предложения: 'Предложения',
};
function localizeProseHeading(raw) {
const cleaned = String(raw || '').replace(/[*_`#]/g, '').trim();
if (!cleaned) {
return null;
}
const key = cleaned.toLowerCase().replace(/\s+/g, ' ');
if (/^json\s*patch$/.test(key) || /^патч$/.test(key) || /^json\s*патч$/.test(key)) {
return null;
}
if (PROSE_SECTION_TITLES[key]) {
return PROSE_SECTION_TITLES[key];
}
// "Critique — blur" → take first token bucket
const head = key.split(/[—:\-|]/)[0].trim();
if (PROSE_SECTION_TITLES[head]) {
return PROSE_SECTION_TITLES[head];
}
return cleaned;
}
function formatProseInline(escapedLine) {
let t = escapedLine;
t = t.replace(/`([^`]+)`/g, '<code class="sa-prose-code">$1</code>');
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
t = t.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
return t;
}
/** Lightweight chat prose: ### Critique → «Критика», lists, bold — not a full markdown engine. */
function formatAssistantProseHtml(raw) {
let text = String(raw || '').replace(/\r\n/g, '\n');
text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n');
text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n');
text = text.replace(/\n{3,}/g, '\n\n').trim();
if (!text) {
return '';
}
const lines = text.split('\n');
const parts = [];
let listItems = [];
const flushList = () => {
if (!listItems.length) {
return;
}
parts.push(
`<ul class="sa-prose-list">${listItems.map((li) => `<li>${formatProseInline(escapeHtml(li))}</li>`).join('')}</ul>`,
);
listItems = [];
};
for (const line of lines) {
const heading = line.match(/^#{1,3}\s+(.+?)\s*$/);
if (heading) {
flushList();
const title = localizeProseHeading(heading[1]);
if (!title) {
continue;
}
const level = Math.min((line.match(/^#+/) || ['###'])[0].length, 3);
parts.push(
`<div class="sa-prose-h sa-prose-h${level}" role="heading" aria-level="${level}">${escapeHtml(title)}</div>`,
);
continue;
}
const bullet = line.match(/^\s*[-*•]\s+(.+)$/);
if (bullet) {
listItems.push(bullet[1]);
continue;
}
flushList();
if (!line.trim()) {
parts.push('<div class="sa-prose-gap" aria-hidden="true"></div>');
continue;
}
parts.push(`<p class="sa-prose-p">${formatProseInline(escapeHtml(line))}</p>`);
}
flushList();
return parts.join('');
}
function setAssistantBody(div, text, { live = false } = {}) {
if (!div) {
return;
}
let body = div.querySelector('.sa-msg-body');
if (!body) {
body = document.createElement('div');
body.className = 'sa-msg-body';
div.appendChild(body);
}
const raw = text || '';
// 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;
} else {
body.textContent = '';
}
}
function val(id) {
const el = document.getElementById(id);
return el ? el.value : '';
}
function setVal(id, value) {
const el = document.getElementById(id);
if (!el) {
return;
}
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function isEmptyParamField(raw, { treatZeroEmpty = false } = {}) {
if (raw == null) {
return true;
}
const s = String(raw).trim();
if (s === '') {
return true;
}
if (treatZeroEmpty && (s === '0' || Number(s) === 0)) {
return true;
}
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');
}
/** Parse aspect from chat: 9:16, 9x16, 9×16, «9 на 16», «9 к 16». */
function parseAspectFromUserText(text) {
const t = String(text || '');
if (!t.trim()) {
return null;
}
const ratio = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*[:x×хX]\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/);
if (ratio) {
const key = normalizeAspect(`${ratio[1]}:${ratio[2]}`);
if (key) {
return key;
}
}
const na = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*(?:на|к|to)\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/i);
if (na) {
const key = normalizeAspect(`${na[1]}:${na[2]}`);
if (key) {
return key;
}
}
const named = t.match(/\b(16:9|9:16|1:1|4:5|2:3|3:2|4:3|2\.35:1)\b/i);
if (named) {
return normalizeAspect(named[1]);
}
if (cyrTokenRe('портрет|вертикал[а-яё]*').test(t) || /\b(portrait|vertical)\b/i.test(t)) {
return normalizeAspect('9:16') || normalizeAspect('2:3');
}
if (cyrTokenRe('альбом|горизонтал[а-яё]*').test(t) || /\b(landscape|horizontal|widescreen)\b/i.test(t)) {
return normalizeAspect('16:9');
}
return null;
}
/** «такую же, только 9 на 16» — keep prompt, change aspect, generate (no LLM needed). */
function isSameButAspectRequest(text) {
const t = String(text || '');
if (!parseAspectFromUserText(t)) {
return false;
}
return /такую\s+же|тот\s+же\s+промпт|same\s+(one|prompt|thing|again)|только\s+(поменя|смени|поставь)|поменяй\s+на|смени\s+на|only\s+change|just\s+change/i.test(t)
|| /поменяй\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;
}
// Do not match bare «вкус» — too common in RU chat and was disabling the echo filter.
return cyrTokenRe('хорни|остынь|слайдер').test(t)
|| /\/\s*(остынь|ostyn|horny-game)/i.test(t)
|| /слайдер\s*вкус|вкус\s*(на|в)\s*\d|поставь\s*вкус|крутани\s*вкус/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 control noise; drop default-echo 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;
}
// Image patches must not move Хорни / Вкус unless the user asked this turn.
if (patchLooksLikeGeneration(patch) && !state.lastUserControlIntent) {
return out;
}
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 (!state.lastUserControlIntent
&& 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)) {
return true;
}
if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) {
return true;
}
return cyrTokenRe('размер|ширин[а-яё]*|высот[а-яё]*|соотношен[а-яё]*|турбо|портрет|вертикал[а-яё]*').test(t);
}
function replyMissingJsonPatch(reply) {
const t = String(reply || '');
if (!t.trim()) {
return false;
}
if (/```(?:json)?\s*\{[\s\S]*?\}```/i.test(t)) {
return false;
}
return /###\s*JSON\s*Patch\b/i.test(t) || /JSON\s*Patch\s*:?\s*$/im.test(t);
}
function userAsksGenerate(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
// Short imperatives only — do NOT treat bare «давай» as Generate (false positive on chat).
if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
return true;
}
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);
}
/** «давай дальше / продолжай / следующий кадр» — continue the series with Generate. */
function userAsksContinue(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(давай\s+дальше|продолжай|продолжим|go\s+on|continue|keep\s+going|next(\s+one)?|next\s+frame)([!.…\s]|$)/i.test(t)) {
return true;
}
return cyrTokenRe(
'давай\\s+дальше|следующ(ий|ая|ее|ую)\\s+кадр|ещё\\s+кадр|еще\\s+кадр|'
+ 'кадр\\s*№?\\s*\\d+|сделай\\s+следующ',
).test(t);
}
/** Pull a usable prompt out of assistant prose when the JSON fence is missing. */
function extractPromptFromProse(reply) {
const t = String(reply || '').replace(/\r\n/g, '\n');
if (!t.trim()) {
return null;
}
const bq = [];
for (const line of t.split('\n')) {
const m = line.match(/^\s{0,3}>\s?(.*)$/);
if (m) {
bq.push(m[1]);
continue;
}
if (bq.length) {
break;
}
}
const fromBq = bq.join('\n').trim();
if (fromBq.length >= 48) {
return fromBq.slice(0, 4000);
}
const section = t.match(
/(?:^|\n)#{1,6}\s*(?:📷\s*)?(?:prompt|промпт|improved\s+prompt|next\s+prompt|кадр[^\n]*)\s*\n+([\s\S]+?)(?=\n#{1,6}\s|\n```|$)/i,
);
if (section) {
const body = section[1].replace(/^\s{0,3}>\s?/gm, '').trim();
if (body.length >= 48) {
return body.slice(0, 4000);
}
}
return null;
}
/** When the model wrote ### JSON Patch with no fence — build Apply+Generate from prose / last patch. */
function synthesizePatchAfterEmptyFence(reply, userText, opts = {}) {
const wants = !!(opts.userWantsGenerate || state.pendingSilentGen
|| userAsksGenerate(userText) || userAsksContinue(userText));
const missing = replyMissingJsonPatch(reply);
if (!wants && !missing) {
return null;
}
const fromProse = extractPromptFromProse(reply);
const prompt = fromProse || state.lastPatch?.prompt || null;
if (!prompt) {
return null;
}
const patch = { prompt, actions: ['generate'] };
if (Array.isArray(state.lastPatch?.loras) && state.lastPatch.loras.length) {
patch.loras = state.lastPatch.loras;
}
if (state.lastPatch?.aspect) {
patch.aspect = state.lastPatch.aspect;
}
return patch;
}
/**
* Chat model prepares Generate prompts for Krea. Skip the prep hop only when the
* prompt already looks like solid English Krea prose for Qwen3-VL.
*/
function promptLooksKreaReady(prompt) {
const t = String(prompt || '').trim();
if (t.length < 80) {
return false;
}
const cyr = (t.match(/[\u0400-\u04FF]/g) || []).length;
const lat = (t.match(/[A-Za-z]/g) || []).length;
if (cyr >= 12) {
return false;
}
if (lat < 55) {
return false;
}
// Prefer a real paragraph (clauses), not a one-liner dump.
if (t.length < 120 && (t.match(/[,.;:]/g) || []).length < 2) {
return false;
}
return true;
}
function promptNeedsKreaPrep(prompt) {
return !promptLooksKreaReady(prompt);
}
function buildKreaPromptPrepRequest(patch) {
const keep = {
actions: Array.isArray(patch.actions) && patch.actions.length ? patch.actions : ['generate'],
};
if (patch.aspect) {
keep.aspect = patch.aspect;
}
if (Array.isArray(patch.loras)) {
keep.loras = patch.loras;
}
if (patch.width != null) {
keep.width = patch.width;
}
if (patch.height != null) {
keep.height = patch.height;
}
return (
'You are the Krea 2 prompt prep step (chat model). Rewrite SOURCE into the final Swarm Generate box text.\n'
+ 'HARD RULES:\n'
+ '- JSON "prompt": English only (no Cyrillic) — translate if needed.\n'
+ '- Natural photographer/director prose for Qwen3-VL — not Danbooru tags, not (word:1.5), not masterpiece/best quality/8k.\n'
+ '- Structure & front-load: subject → pose/action → body/wardrobe → setting → materials/textures → camera/framing → lighting → medium/mood.\n'
+ '- Expand thin ideas; fix anti-patterns; one coherent scene.\n'
+ '- Keep LoRA trigger phrases in English near the subject they affect.\n'
+ '- Prefer positives over negatives; preserve meaning and NSFW level from SOURCE.\n'
+ '- One short ack in the user language max, then ONE fenced JSON merging these keys: '
+ `${JSON.stringify(keep)} plus the new English "prompt".\n`
+ '- Include actions:["generate"] when an image was requested.\n\n'
+ `SOURCE:\n${patch.prompt}`
);
}
function mergePromptEnRewrite(effective) {
const base = state.pendingPromptEnMerge;
state.pendingPromptEnMerge = null;
if (!base || !effective) {
return effective;
}
return {
...base,
...effective,
prompt: effective.prompt || base.prompt,
actions: (Array.isArray(effective.actions) && effective.actions.length)
? effective.actions
: (base.actions || ['generate']),
loras: effective.loras || base.loras,
aspect: effective.aspect || base.aspect,
};
}
/** «запомни как базовый промпт» — apply/save only, never Generate / auto look_at. */
function userAsksNoGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksGenerate(t)) {
return false;
}
if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
return true;
}
return cyrTokenRe(
'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
+ 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
+ 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
+ 'не\\s+рисуй|не\\s+запускай\\s+генер',
).test(t);
}
function stripGenerateAction(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (!Array.isArray(patch.actions)) {
return patch;
}
const next = patch.actions.map(String).filter((a) => a !== 'generate');
if (next.length === patch.actions.length) {
return patch;
}
const out = { ...patch };
if (next.length) {
out.actions = next;
} else {
delete out.actions;
}
return out;
}
function stripLookAt(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (patch.look_at == null && patch.vision_from == null && patch.vision_slots == null) {
return patch;
}
const out = { ...patch };
delete out.look_at;
delete out.vision_from;
delete out.vision_slots;
return out;
}
function rememberLastPatch(patch) {
if (patch && typeof patch === 'object' && !isCardObject(patch)) {
state.lastPatch = patch;
syncBuildGenButton();
}
}
function syncBuildGenButton() {
const btn = $('sa_btn_build_gen');
if (!btn) {
return;
}
if (state.lastPatch) {
const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null).slice(0, 6);
btn.title = `Есть патч Assistent (${keys.join(', ') || '…'}) → Apply + Generate`;
btn.classList.add('sa-has-patch');
} else {
btn.title = 'Нет патча — Generate с текущим промптом SwarmUI';
btn.classList.remove('sa-has-patch');
}
}
function defaultPackId() {
return state.config?.assistant?.default_pack
|| $('sa_pack')?.querySelector('option')?.value
|| 'ordinary';
}
function syncModeBadge() {
const badge = $('sa_mode_badge');
const pack = $('sa_pack')?.value || defaultPackId();
if (!badge) {
return;
}
const shortMap = {
ordinary: 'обычный',
write_prompt: 'write',
critique_image: 'critique',
compose_scene: 'compose',
fix_params: 'params',
inpaint_edit: 'inpaint',
describe_ref: 'describe',
catalog_card: 'card',
author_persona: 'persona',
};
const short = shortMap[pack] || pack.replace(/_/g, ' ').slice(0, 12);
badge.textContent = short;
badge.dataset.pack = pack;
badge.title = `Режим: ${pack}`;
badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit');
}
function syncLiveParamsBar() {
const el = $('sa_live_params');
if (!el) {
return;
}
const w = parseInt(val('input_width') || '0', 10) || null;
const h = parseInt(val('input_height') || '0', 10) || null;
const aspect = guessAspectFromSize(w, h) || '—';
const steps = val('input_steps') || '—';
const cfg = val('input_cfgscale') || val('input_cfg') || '—';
const seed = val('input_seed') || '—';
const profile = detectKreaProfileName();
el.textContent = `${aspect} · ${w || '?'}×${h || '?'} · steps ${steps} · cfg ${cfg} · ${profile} · seed ${seed}`;
}
function applyAspectTableFrom(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const next = {};
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v) && v.length >= 2) {
next[k] = [Number(v[0]), Number(v[1])];
}
}
if (!Object.keys(next).length) {
return false;
}
ASPECT_TABLE = next;
return true;
}
function resolveExactBundle() {
const exact = state.exact || state.config?.exact || {};
const profiles = exact.profiles || state.kreaProfiles || {};
return { exact, profiles };
}
function detectKreaProfileName() {
try {
const model = resolveCurrentCheckpoint();
const blob = `${model?.name || ''} ${model?.title || ''}`.toLowerCase();
const hasRaw = /\braw\b/.test(blob);
const hasTurbo = /\bturbo\b/.test(blob);
return hasRaw && !hasTurbo ? 'raw' : 'turbo';
} catch (e) {
return (state.exact?.generation?.profile) || 'turbo';
}
}
function mergedGenerationDefaults(profileName) {
const { exact, profiles } = resolveExactBundle();
const gen = exact.generation && typeof exact.generation === 'object' ? { ...exact.generation } : {};
const profile = profileName || gen.profile || detectKreaProfileName();
const fromProfile = profiles[profile] && typeof profiles[profile] === 'object' ? { ...profiles[profile] } : {};
const session = state.sessionExact && typeof state.sessionExact === 'object' ? { ...state.sessionExact } : {};
// Profile (turbo/raw) overrides generation defaults; session overrides both.
return { ...gen, ...fromProfile, profile, ...session };
}
function exactDefaultFor(key, profileName) {
const { exact, profiles } = resolveExactBundle();
const profile = profileName || exact.generation?.profile || detectKreaProfileName();
const fromProfile = profiles[profile]?.[key];
if (fromProfile != null) {
return fromProfile;
}
return exact.generation?.[key];
}
function rememberSessionExact(partial) {
if (state.restoringChat || !partial || typeof partial !== 'object') {
return;
}
const keys = ['steps', 'cfg', 'sigma_shift', 'aspect', 'width', 'height', 'images', 'batch', 'seed', 'sampler', 'scheduler'];
for (const k of keys) {
if (partial[k] != null) {
state.sessionExact[k] = partial[k];
}
}
if (partial.images == null && partial.batch != null) {
state.sessionExact.images = partial.batch;
}
}
function fillEmptyParamsFromExact() {
const defaults = mergedGenerationDefaults();
if (isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) {
setVal('input_steps', String(defaults.steps));
}
const cfgRaw = val('input_cfgscale') || val('input_cfg');
if (isEmptyParamField(cfgRaw, { treatZeroEmpty: true }) && defaults.cfg != null) {
if (document.getElementById('input_cfgscale')) {
setVal('input_cfgscale', String(defaults.cfg));
} else if (document.getElementById('input_cfg')) {
setVal('input_cfg', String(defaults.cfg));
}
}
if (isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
setVal('input_sigmashift', String(defaults.sigma_shift));
}
const wEmpty = isEmptyParamField(val('input_width'), { treatZeroEmpty: true });
const hEmpty = isEmptyParamField(val('input_height'), { treatZeroEmpty: true });
if ((wEmpty || hEmpty) && defaults.aspect) {
const size = sizeFromAspect(defaults.aspect);
if (size) {
if (wEmpty) {
setVal('input_width', String(size[0]));
}
if (hEmpty) {
setVal('input_height', String(size[1]));
}
}
} else {
if (wEmpty && defaults.width != null) {
setVal('input_width', String(defaults.width));
}
if (hEmpty && defaults.height != null) {
setVal('input_height', String(defaults.height));
}
}
const batchId = document.getElementById('input_images') ? 'input_images' : (document.getElementById('input_batchsize') ? 'input_batchsize' : null);
if (batchId && isEmptyParamField(val(batchId), { treatZeroEmpty: true })) {
const batch = defaults.images != null ? defaults.images : defaults.batch;
if (batch != null) {
setVal(batchId, String(batch));
}
}
}
function shouldSkipSessionRollback(key, patchValue) {
if (state.restoringChat) {
return false;
}
if (state.lastUserParamIntent) {
return false;
}
if (state.sessionExact[key] == null) {
return false;
}
const sessionVal = state.sessionExact[key];
if (String(sessionVal) === String(patchValue)) {
return false;
}
const exactVal = exactDefaultFor(key);
if (exactVal == null) {
return false;
}
// Model trying to restore file exact while session override differs — keep session.
return String(patchValue) === String(exactVal);
}
function openAssistentTab() {
const tab = document.getElementById(TAB_BUTTON_ID);
if (tab) {
tab.click();
setTimeout(() => $('sa_input')?.focus(), 50);
return true;
}
const pane = document.getElementById('assistent');
if (pane && typeof bootstrap !== 'undefined' && bootstrap.Tab) {
try {
bootstrap.Tab.getOrCreateInstance(tab || pane).show();
} catch (e) { /* ignore */ }
}
setTimeout(() => $('sa_input')?.focus(), 50);
return !!tab;
}
function historyMessageLimit() {
const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
return turns * 2;
}
function flashImagePane(slotId) {
const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
if (!el) {
return;
}
el.classList.remove('sa-flash');
void el.offsetWidth;
el.classList.add('sa-flash');
}
function ensureBoard() {
if (state.slots.length) {
return;
}
state.slots = [
{ id: GEN_ID, type: 'generate', label: 'Generate', src: null, attach: false },
{ id: 'ref1', type: 'ref', label: 'Ref 1', src: null, attach: true },
];
state.refSeq = 1;
state.selectedSlotId = 'ref1';
}
function slotById(id) {
ensureBoard();
const key = normalizeSlotId(id);
return state.slots.find((s) => s.id === key) || null;
}
function generateSlot() {
return slotById(GEN_ID);
}
function refSlots() {
ensureBoard();
return state.slots.filter((s) => s.type === 'ref');
}
function normalizeSlotId(id) {
const raw = String(id || '').trim().toLowerCase();
if (!raw) {
return '';
}
if (raw === 'gen' || raw === 'current' || raw === 'live' || raw === 'generation') {
return GEN_ID;
}
if (raw === 'selected' || raw === 'sel') {
return state.selectedSlotId;
}
const m = raw.match(/^ref\s*[_-]?\s*(\d+)$/);
if (m) {
return `ref${m[1]}`;
}
return raw;
}
function selectedSlot() {
return slotById(state.selectedSlotId) || generateSlot();
}
function selectedSrc() {
return selectedSlot()?.src || null;
}
function syncLastImageAlias() {
const attached = attachableSlots();
state.lastImageDataUrl = (attached[0] || selectedSlot() || generateSlot())?.src || null;
}
function attachableSlots() {
ensureBoard();
return state.slots.filter((s) => s.attach && s.src);
}
/** Real board frames (not model previews). Used for has_vision_image even when JPEG is not sent. */
function visionReadySlots() {
ensureBoard();
return state.slots.filter((s) => s && s.src && !looksLikeModelPreview(s.src));
}
function setSlotSrc(id, src, { select = true, attach = null, note = null, switchTab = false, allowPreview = false } = {}) {
const slot = slotById(id);
if (!slot) {
return false;
}
const cleaned = src ? String(src).trim().split(/\s+/)[0] : null;
if (cleaned && cleaned.startsWith('#')) {
return false;
}
if (cleaned && !allowPreview && looksLikeModelPreview(cleaned)) {
setStatus('Пропуск превью модели (нужна реальная генерация)');
return false;
}
slot.src = cleaned || null;
if (attach != null) {
slot.attach = !!attach;
} else if (slot.type === 'ref' && slot.src) {
slot.attach = true;
}
if (select) {
state.selectedSlotId = slot.id;
}
syncLastImageAlias();
renderBoard();
flashImagePane(slot.id);
if (switchTab) {
openAssistentTab();
}
if (note) {
setStatus(note);
}
return true;
}
function addRefSlot({ src = null, select = true } = {}) {
ensureBoard();
if (refSlots().length >= MAX_REF_SLOTS) {
setStatus(`Max ${MAX_REF_SLOTS} reference windows`);
const empty = refSlots().find((s) => !s.src);
if (empty && src) {
return setSlotSrc(empty.id, src, { select, note: `Loaded into ${empty.label}` });
}
return empty || null;
}
state.refSeq += 1;
const id = `ref${state.refSeq}`;
const slot = {
id,
type: 'ref',
label: `Ref ${state.refSeq}`,
src: src || null,
attach: !!src,
};
state.slots.push(slot);
if (select) {
state.selectedSlotId = id;
}
renderBoard();
return slot;
}
function clearSlot(id, { silent = false } = {}) {
const slot = slotById(id);
if (!slot) {
return;
}
if (slot.type === 'generate') {
if (!silent) {
setStatus('Generate window is live — use Snapshot gen to copy it');
}
return;
}
slot.src = null;
slot.attach = true;
syncLastImageAlias();
renderBoard();
if (!silent) {
setStatus(`${slot.label} cleared`);
}
}
function snapshotGenerateToRef() {
// Never promote checkpoint/LoRA card previews into Refs.
const src = generateSlot()?.src && !looksLikeModelPreview(generateSlot().src)
? generateSlot().src
: findCurrentGenerateSrc({ allowPreview: false });
if (!src) {
setStatus('Нет текущего кадра Generate (превью модели не считается)');
return false;
}
const empty = refSlots().find((s) => !s.src);
let ok = false;
if (empty) {
ok = setSlotSrc(empty.id, src, { note: `Снимок → ${empty.label}` });
} else {
const created = addRefSlot({ src, select: true });
if (created?.src) {
setStatus(`Снимок → ${created.label}`);
flashImagePane(created.id);
ok = true;
} else {
const last = refSlots()[refSlots().length - 1];
if (last) {
ok = setSlotSrc(last.id, src, { note: `Снимок → ${last.label} (замена)` });
}
}
}
if (ok) {
setBoardTab('refs');
}
return ok;
}
function putImageOnBoard(src, { note = null, switchTab = false, preferSelected = true } = {}) {
if (!src) {
return false;
}
ensureBoard();
const sel = selectedSlot();
if (preferSelected && sel && sel.type === 'ref') {
return setSlotSrc(sel.id, src, { note: note || `Loaded into ${sel.label}`, switchTab });
}
const empty = refSlots().find((s) => !s.src);
if (empty) {
return setSlotSrc(empty.id, src, { note: note || `Loaded into ${empty.label}`, switchTab });
}
const created = addRefSlot({ src, select: true });
if (created) {
if (switchTab) {
openAssistentTab();
}
if (note) {
setStatus(note);
}
return true;
}
return false;
}
function setImageFromSrc(src, opts = {}) {
return putImageOnBoard(src, opts);
}
function clearVisionImage(opts) {
clearSlot(state.selectedSlotId, opts);
}
function slotCatalog() {
ensureBoard();
return state.slots.map((s) => ({
id: s.id,
type: s.type,
label: s.label,
has_image: !!s.src,
attach: !!s.attach,
selected: s.id === state.selectedSlotId,
}));
}
function lookAtIdsFromPatch(patch) {
if (!patch) {
return [];
}
const raw = patch.look_at || patch.vision_from || patch.vision_slots;
const list = Array.isArray(raw) ? raw : (raw ? [raw] : []);
if (Array.isArray(patch.actions)) {
for (const a of patch.actions.map(String)) {
const m = a.match(/^look_at[_:]?(generate|ref\d+|selected)$/i);
if (m) {
list.push(m[1]);
}
}
}
return [...new Set(list.map(normalizeSlotId).filter(Boolean))];
}
function resolveSlotSrc(id) {
if (!id) {
return selectedSrc() || generateSlot()?.src || findCurrentGenerateSrc();
}
const slot = slotById(id);
if (slot?.src) {
return slot.src;
}
if (normalizeSlotId(id) === GEN_ID) {
return findCurrentGenerateSrc();
}
return null;
}
function isSwarmGenerateRunning() {
try {
if (typeof num_live_gens === 'number' && num_live_gens > 0) {
return true;
}
if (typeof num_waiting_gens === 'number' && num_waiting_gens > 0) {
return true;
}
} catch (e) { /* ignore */ }
try {
if (typeof mainGenHandler !== 'undefined' && mainGenHandler) {
if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) {
return true;
}
}
} catch (e) { /* ignore */ }
const interrupt = document.getElementById('interrupt_button')
|| document.getElementById('alt_interrupt_button');
if (interrupt && !interrupt.hidden && interrupt.offsetParent !== null) {
return true;
}
const genBtn = document.getElementById('generate_button') || document.getElementById('alt_generate_button');
if (genBtn && (genBtn.disabled || /interrupt/i.test(genBtn.textContent || ''))) {
return true;
}
return false;
}
function isGenerateUnavailable() {
if (state.generating || state.busy) {
return true;
}
return isSwarmGenerateRunning();
}
function syncGenerateBusy() {
const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
if (overlay) {
// If Swarm already finished but our waiter is stuck on same URL, drop the overlay
// as soon as the live frame is on the board.
const stuck = state.generating && !isSwarmGenerateRunning();
overlay.hidden = (!state.generating && state.busyPhase !== 'generating') || stuck;
}
const running = state.generating || state.busyPhase === 'generating';
document.querySelectorAll('.sa-slot-gen-result').forEach((el) => {
const busy = el.querySelector('.sa-slot-busy');
if (!busy) {
return;
}
const hasImg = el.classList.contains('sa-has-image');
busy.hidden = !running || hasImg;
});
}
function syncPatchActionAvailability() {
const bar = document.querySelector('.sa-patch-actions.sa-patch-current');
if (!bar) {
return;
}
const locked = isGenerateUnavailable();
bar.querySelectorAll('.sa-btn-gen').forEach((btn) => {
btn.disabled = locked;
let spin = btn.querySelector('.sa-spinner');
if (locked) {
if (!spin) {
spin = document.createElement('span');
spin.className = 'sa-spinner sa-spinner-btn';
spin.setAttribute('aria-hidden', 'true');
btn.prepend(spin);
}
} else if (spin) {
spin.remove();
}
});
}
function retireStalePatchActions() {
document.querySelectorAll('.sa-patch-actions').forEach((el) => {
const note = document.createElement('div');
note.className = 'sa-patch-stale';
note.textContent = 'Superseded — use the latest proposal';
el.replaceWith(note);
});
}
function mountPatchBlock(host, patch, { silent = false } = {}) {
if (!host || !patch) {
return;
}
rememberLastPatch(patch);
const wrap = document.createElement('div');
wrap.className = 'sa-patch' + (silent ? ' sa-patch-auto' : '');
const details = document.createElement('details');
details.className = 'sa-patch-details';
const summary = document.createElement('summary');
const keys = Object.keys(patch).filter((k) => patch[k] != null && k !== 'notes' && k !== 'actions');
summary.textContent = silent
? `Патч применён · ${keys.slice(0, 6).join(', ') || 'generate'}`
: `JSON патч · ${keys.slice(0, 8).join(', ') || '…'}`;
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(patch, null, 2);
details.appendChild(summary);
details.appendChild(pre);
wrap.appendChild(details);
mountPatchActions(wrap, patch, { silent });
host.appendChild(wrap);
}
function mountPatchActions(parent, patch, { silent = false } = {}) {
if (!parent || !patch) {
return;
}
rememberLastPatch(patch);
retireStalePatchActions();
const wrap = parent.classList.contains('sa-patch') ? parent : null;
const host = wrap || parent;
if (silent) {
const note = document.createElement('div');
note.className = 'sa-patch-actions sa-patch-silent sa-patch-current';
const willGen = Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')
|| !!state.pendingSilentGen;
note.textContent = willGen
? 'Применено автоматически · Generate…'
: 'Применено автоматически';
host.appendChild(note);
return;
}
const actions = document.createElement('div');
actions.className = 'sa-patch-actions sa-patch-current';
for (const [label, which] of [
['Применить всё', 'all'],
['Промпт', 'prompt'],
['LoRAs', 'loras'],
['Параметры', 'params'],
]) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'basic-button';
btn.textContent = label;
btn.addEventListener('click', () => applyPatch(patch, which));
actions.appendChild(btn);
}
const genBtn = document.createElement('button');
genBtn.type = 'button';
genBtn.className = 'basic-button sa-btn-gen';
genBtn.textContent = 'Применить + Generate';
genBtn.addEventListener('click', async () => {
if (isGenerateUnavailable()) {
return;
}
startBusyUi('silent_gen');
await applyPatch(patch, 'all');
await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
});
actions.appendChild(genBtn);
host.appendChild(actions);
syncPatchActionAvailability();
}
async function buildCurrentAndGenerate() {
if (state.busy || state.generating) {
setStatus('Занято — подожди или нажми Стоп');
return;
}
if (isGenerateUnavailable()) {
setStatus('Generate недоступен — дождись SwarmUI');
return;
}
const patch = state.lastPatch;
if (patch) {
startBusyUi('silent_gen');
setStatus('Собираю патч → Generate…');
await applyPatch(patch, 'all');
syncLiveParamsBar();
await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
return;
}
startBusyUi('generating');
setStatus('Generate с текущим промптом…');
await runGenerateFromPatch({ actions: ['generate'] }, { force: true });
}
function renderBoard() {
const board = $('sa_board');
if (!board) {
return;
}
ensureBoard();
const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
const refs = refSlots();
const showVariantGrid = tab === 'generate' && (state.genResults || []).length > 1;
board.classList.toggle('sa-board-many', (tab === 'refs' && (refs.some((s) => s.src) || refs.length > 1)) || showVariantGrid);
board.classList.toggle('sa-board-gen-only', tab === 'generate' && !showVariantGrid);
board.classList.toggle('sa-board-variants', showVariantGrid);
board.innerHTML = '';
if (tab === 'generate' && showVariantGrid) {
for (const row of state.genResults) {
board.appendChild(buildGenResultEl(row));
}
} else {
const toShow = tab === 'generate'
? state.slots.filter((s) => s.type === 'generate')
: state.slots.filter((s) => s.type !== 'generate');
for (const slot of toShow) {
board.appendChild(buildSlotEl(slot));
}
}
if (tab === 'refs' && refs.length < MAX_REF_SLOTS) {
const add = document.createElement('div');
add.className = 'sa-add-cell';
add.textContent = '+ Ref';
add.title = 'Добавить окно референса';
add.addEventListener('click', (e) => {
e.stopPropagation();
addRefSlot({ select: true });
});
add.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
});
add.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
const created = addRefSlot({ select: true });
if (created) {
state.selectedSlotId = created.id;
await handleDropDataTransfer(e.dataTransfer, created.id);
}
});
board.appendChild(add);
}
syncBoardChrome();
syncGenerateBusy();
syncLastImageAlias();
}
function buildGenResultEl(row) {
const el = document.createElement('div');
el.className = 'sa-slot sa-slot-gen-result';
el.dataset.genResultId = row.id;
if (row.src) {
el.classList.add('sa-has-image');
}
if (row.id === state.selectedGenResultId) {
el.classList.add('sa-selected');
}
const bar = document.createElement('div');
bar.className = 'sa-slot-bar';
const chip = document.createElement('span');
chip.className = 'sa-slot-chip sa-live';
chip.textContent = row.label || row.id;
bar.appendChild(chip);
const openBtn = document.createElement('button');
openBtn.type = 'button';
openBtn.className = 'sa-slot-open';
openBtn.textContent = 'Открыть';
openBtn.title = 'Просмотр';
openBtn.hidden = !row.src;
openBtn.addEventListener('click', (e) => {
e.stopPropagation();
selectGenResult(row.id, { restore: true, openViewer: true });
});
bar.appendChild(openBtn);
el.appendChild(bar);
if (row.src) {
const img = document.createElement('img');
img.alt = row.label || row.id;
img.src = row.src;
el.appendChild(img);
} else {
const empty = document.createElement('div');
empty.className = 'sa-image-empty';
empty.innerHTML = '<div class="sa-empty-title">…</div><div class="sa-empty-hint">Жду кадр</div>';
el.appendChild(empty);
}
const busy = document.createElement('div');
busy.className = 'sa-slot-busy';
const pending = !row.src && (state.generating || state.busyPhase === 'generating');
busy.hidden = !pending;
busy.innerHTML = '<span class="sa-spinner" aria-hidden="true"></span>';
el.appendChild(busy);
el.addEventListener('click', () => {
const already = row.id === state.selectedGenResultId;
selectGenResult(row.id, { restore: true, openViewer: already && !!row.src });
});
el.addEventListener('dblclick', (e) => {
e.preventDefault();
if (row.src) {
selectGenResult(row.id, { restore: true, openViewer: true });
}
});
return el;
}
function ensureGenLightbox() {
let root = $('sa_gen_lightbox');
if (root) {
return root;
}
const host = $('swarm_assistent_root') || document.body;
root = document.createElement('div');
root.id = 'sa_gen_lightbox';
root.className = 'sa-lightbox';
root.hidden = true;
root.innerHTML = `
<div class="sa-lightbox-backdrop" data-lb="close"></div>
<div class="sa-lightbox-panel" role="dialog" aria-modal="true" aria-label="Просмотр варианта">
<div class="sa-lightbox-head">
<span class="sa-lightbox-title" id="sa_lb_title"></span>
<span class="sa-lightbox-idx" id="sa_lb_idx"></span>
<button type="button" class="basic-button sa-icon-btn" data-lb="close" title="Закрыть (Esc)">✕</button>
</div>
<div class="sa-lightbox-body">
<button type="button" class="sa-lightbox-nav sa-lightbox-prev" data-lb="prev" title="Назад"></button>
<img id="sa_lb_img" alt="" />
<button type="button" class="sa-lightbox-nav sa-lightbox-next" data-lb="next" title="Вперёд"></button>
</div>
<div class="sa-lightbox-actions">
<button type="button" class="basic-button" data-lb="to_ref">В Refs</button>
<button type="button" class="basic-button" data-lb="as_init">Как Init</button>
<button type="button" class="basic-button" data-lb="close">Закрыть</button>
</div>
</div>`;
host.appendChild(root);
root.addEventListener('click', async (e) => {
const act = e.target?.closest?.('[data-lb]')?.getAttribute('data-lb');
if (!act) {
return;
}
e.preventDefault();
e.stopPropagation();
if (act === 'close') {
closeGenLightbox();
} else if (act === 'prev') {
stepGenLightbox(-1);
} else if (act === 'next') {
stepGenLightbox(1);
} else if (act === 'to_ref') {
const row = currentLightboxRow();
if (row?.src) {
const created = addRefSlot({ select: true });
if (created) {
created.src = row.src;
setBoardTab('refs');
renderBoard();
setStatus(`Снимок → ${created.label}`);
}
}
} else if (act === 'as_init') {
const row = currentLightboxRow();
if (row?.src) {
await setInitFromSrc(row.src);
}
}
});
return root;
}
function currentLightboxRow() {
const list = (state.genResults || []).filter((r) => r.src);
if (!list.length || state.lightboxIndex < 0) {
return null;
}
return list[state.lightboxIndex] || null;
}
function syncGenLightbox() {
const root = ensureGenLightbox();
const list = (state.genResults || []).filter((r) => r.src);
const row = list[state.lightboxIndex];
if (!row) {
root.hidden = true;
return;
}
root.hidden = false;
const img = $('sa_lb_img');
const title = $('sa_lb_title');
const idx = $('sa_lb_idx');
if (img) {
img.src = row.src;
img.alt = row.label || row.id;
}
if (title) {
title.textContent = row.label || row.id;
}
if (idx) {
idx.textContent = `${state.lightboxIndex + 1} / ${list.length}`;
}
}
function openGenLightbox(id) {
const list = (state.genResults || []).filter((r) => r.src);
let idx = list.findIndex((r) => r.id === id);
if (idx < 0) {
idx = 0;
}
if (!list.length) {
return;
}
state.lightboxIndex = idx;
ensureGenLightbox();
syncGenLightbox();
selectGenResult(list[idx].id, { restore: true, openViewer: false });
}
function closeGenLightbox() {
state.lightboxIndex = -1;
const root = $('sa_gen_lightbox');
if (root) {
root.hidden = true;
}
}
function stepGenLightbox(delta) {
const list = (state.genResults || []).filter((r) => r.src);
if (list.length < 2) {
return;
}
state.lightboxIndex = (state.lightboxIndex + delta + list.length) % list.length;
const row = list[state.lightboxIndex];
if (row) {
selectGenResult(row.id, { restore: true, openViewer: false });
}
syncGenLightbox();
}
function syncBoardChrome() {
const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
$('sa_board_tab_gen')?.classList.toggle('sa-board-tab-active', tab === 'generate');
$('sa_board_tab_refs')?.classList.toggle('sa-board-tab-active', tab === 'refs');
$('sa_board_tab_gen')?.setAttribute('aria-selected', tab === 'generate' ? 'true' : 'false');
$('sa_board_tab_refs')?.setAttribute('aria-selected', tab === 'refs' ? 'true' : 'false');
const addBtn = $('sa_btn_add_ref');
if (addBtn) {
addBtn.hidden = tab !== 'refs';
}
const maskBtn = $('sa_btn_as_mask');
const clearSlotBtn = $('sa_btn_clear_image');
if (maskBtn) {
maskBtn.hidden = tab !== 'refs';
}
if (clearSlotBtn) {
clearSlotBtn.hidden = tab !== 'refs';
}
const badge = $('sa_refs_badge');
if (badge) {
const refs = refSlots();
const withImg = refs.filter((s) => s.src).length;
const withVision = refs.filter((s) => s.src && s.attach).length;
if (withImg || withVision) {
badge.hidden = false;
badge.textContent = withVision ? `${withImg} · vision ${withVision}` : String(withImg);
} else {
badge.hidden = true;
}
}
let genBadge = $('sa_gen_badge');
if (!genBadge) {
const genTab = $('sa_board_tab_gen');
if (genTab) {
genBadge = document.createElement('span');
genBadge.id = 'sa_gen_badge';
genBadge.className = 'sa-board-badge';
genBadge.hidden = true;
genTab.appendChild(genBadge);
}
}
if (genBadge) {
const n = finishedGenResultCount();
if (n > 1) {
genBadge.hidden = false;
genBadge.textContent = String(n);
} else {
genBadge.hidden = true;
}
}
}
function setBoardTab(tab, { persist = true } = {}) {
state.boardTab = tab === 'refs' ? 'refs' : 'generate';
if (persist) {
try {
localStorage.setItem(LS_BOARD_TAB, state.boardTab);
} catch (e) { /* ignore */ }
}
renderBoard();
}
function buildSlotEl(slot) {
const el = document.createElement('div');
el.className = `sa-slot${slot.type === 'generate' ? ' sa-slot-gen' : ''}`;
el.dataset.id = slot.id;
if (slot.src) {
el.classList.add('sa-has-image');
}
if (slot.id === state.selectedSlotId) {
el.classList.add('sa-selected');
}
const bar = document.createElement('div');
bar.className = 'sa-slot-bar';
const chip = document.createElement('span');
chip.className = `sa-slot-chip${slot.type === 'generate' ? ' sa-live' : ''}`;
chip.textContent = slot.type === 'generate' ? 'Generate' : slot.label;
bar.appendChild(chip);
const attachLab = document.createElement('label');
attachLab.className = 'sa-slot-attach';
attachLab.title = 'Attach this window to the next chat (vision)';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!slot.attach;
cb.addEventListener('click', (e) => e.stopPropagation());
cb.addEventListener('change', (e) => {
e.stopPropagation();
slot.attach = cb.checked;
syncLastImageAlias();
});
attachLab.appendChild(cb);
attachLab.appendChild(document.createTextNode(' vision'));
bar.appendChild(attachLab);
el.appendChild(bar);
if (slot.src) {
const img = document.createElement('img');
img.alt = slot.label;
img.src = slot.src;
el.appendChild(img);
} else {
const empty = document.createElement('div');
empty.className = 'sa-image-empty';
empty.innerHTML = slot.type === 'generate'
? '<div class="sa-empty-title">Generate</div><div class="sa-empty-hint">Живой просмотр текущей генерации</div>'
: '<div class="sa-empty-title">Reference</div><div class="sa-empty-hint">Drop · paste · Снимок gen</div>';
el.appendChild(empty);
}
const busy = document.createElement('div');
busy.className = 'sa-slot-busy';
busy.hidden = !(slot.type === 'generate' && (state.generating || state.busyPhase === 'generating'));
busy.innerHTML = '<span class="sa-spinner" aria-hidden="true"></span>';
el.appendChild(busy);
el.addEventListener('click', () => {
state.selectedSlotId = slot.id;
renderBoard();
});
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.add('sa-dragover');
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
});
el.addEventListener('dragleave', () => el.classList.remove('sa-dragover'));
el.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
el.classList.remove('sa-dragover');
const targetId = slot.type === 'generate' ? null : slot.id;
if (slot.type === 'generate') {
const created = addRefSlot({ select: true });
await handleDropDataTransfer(e.dataTransfer, created?.id);
setBoardTab('refs');
} else {
await handleDropDataTransfer(e.dataTransfer, targetId);
}
});
return el;
}
function syncGenerateSlot() {
const slot = generateSlot();
if (!slot) {
return;
}
// Drop checkpoint/LoRA card previews that slipped into the Generate pane.
if (scrubPreviewFromGenerateSlot()) {
renderBoard();
}
const src = findCurrentGenerateSrc();
if (src && src !== slot.src) {
slot.src = src;
const img = document.querySelector('.sa-slot-gen img');
const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
const frame = document.querySelector('.sa-slot-gen');
if (img) {
img.src = src;
} else if (frame) {
renderBoard();
return;
}
if (empty) {
empty.hidden = true;
}
frame?.classList.add('sa-has-image');
} else if (src && slot.src === src) {
// Same URL, possibly new bytes after overwrite — nudge <img> once Swarm is idle.
if (state.generating && !isSwarmGenerateRunning()) {
const img = document.querySelector('.sa-slot-gen img');
if (img) {
const bump = src.includes('?') ? `${src}&sa_t=${Date.now()}` : `${src}?sa_t=${Date.now()}`;
img.src = bump;
}
}
} else if (!src && !slot.src) {
const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
const frame = document.querySelector('.sa-slot-gen');
const img = document.querySelector('.sa-slot-gen img');
if (img) {
img.remove();
}
if (empty) {
empty.hidden = false;
}
frame?.classList.remove('sa-has-image', 'sa-attached');
}
syncGenerateBusy();
syncPatchActionAvailability();
}
function maybeWelcome() {
if (localStorage.getItem(LS_WELCOMED) === '1') {
return;
}
if (!$('sa_messages')) {
return;
}
localStorage.setItem(LS_WELCOMED, '1');
const box = $('sa_messages');
hideChatEmpty();
const div = document.createElement('div');
div.className = 'sa-msg assistant sa-welcome';
div.innerHTML = WELCOME_HTML;
box.appendChild(div);
scrollMessagesToBottom({ force: true });
}
function chatUid() {
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, '')
// Drop echoed critique templates / empty patch headers so the next turn doesn't copy them.
.replace(/###\s*Critique\b[\s\S]*?(?=###|$)/gi, '')
.replace(/###\s*JSON\s*Patch\b[\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) => {
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) {
const u = (messages || []).find((m) => m.role === 'user' && m.content);
const t = String(u?.content || '').replace(/\s+/g, ' ').trim();
return t ? t.slice(0, 52) : 'Новый чат';
}
function snapshotChatParams() {
let loras = [];
try {
if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
loras = loraHelper.selected.map((l) => ({
name: l.name || l,
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
}));
}
} catch (e) { /* ignore */ }
let lastPatch = null;
try {
lastPatch = state.lastPatch ? JSON.parse(JSON.stringify(state.lastPatch)) : null;
} catch (e) {
lastPatch = null;
}
let sessionExact = {};
try {
sessionExact = state.sessionExact && typeof state.sessionExact === 'object'
? JSON.parse(JSON.stringify(state.sessionExact))
: {};
} catch (e) {
sessionExact = {};
}
return {
prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
negative: val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '',
width: parseInt(val('input_width') || '0', 10) || null,
height: parseInt(val('input_height') || '0', 10) || null,
steps: parseInt(val('input_steps') || '0', 10) || null,
cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
seed: val('input_seed') || null,
sampler: val('input_sampler') || null,
scheduler: val('input_scheduler') || null,
batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
loras,
persona: $('sa_persona')?.value || 'neutral',
pack: $('sa_pack')?.value || defaultPackId(),
sessionExact,
lastPatch,
genResults: Array.isArray(state.genResults)
? state.genResults.map((r) => ({
id: r.id,
label: r.label,
src: r.src || null,
patch: r.patch || null,
}))
: [],
selectedGenResultId: state.selectedGenResultId || null,
};
}
async function restoreChatParams(params) {
state.restoringChat = true;
try {
// Always reset chat-scoped state so previous chat cannot leak.
state.sessionExact = {};
state.lastPatch = null;
state.lastUserParamIntent = false;
if (!params || typeof params !== 'object') {
clearGenResults();
syncBuildGenButton();
syncLiveParamsBar();
syncModeBadge();
renderBoard();
return { restored: false };
}
const promptBox = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
if (promptBox) {
promptBox.value = params.prompt != null ? String(params.prompt) : '';
promptBox.dispatchEvent(new Event('input', { bubbles: true }));
promptBox.dispatchEvent(new Event('change', { bubbles: true }));
}
setVal('input_negativeprompt', params.negative != null ? String(params.negative) : '');
// Force-write numerics when present so prior chat values cannot stick.
if (params.width != null) {
setVal('input_width', String(params.width));
}
if (params.height != null) {
setVal('input_height', String(params.height));
}
if (params.steps != null) {
setVal('input_steps', String(params.steps));
}
if (params.cfg != null) {
if (document.getElementById('input_cfgscale')) {
setVal('input_cfgscale', String(params.cfg));
} else {
setVal('input_cfg', String(params.cfg));
}
}
if (params.sigma_shift != null) {
setVal('input_sigmashift', String(params.sigma_shift));
}
if (params.seed != null && params.seed !== '') {
setVal('input_seed', String(params.seed));
}
if (params.sampler) {
setVal('input_sampler', String(params.sampler));
}
if (params.scheduler) {
setVal('input_scheduler', String(params.scheduler));
}
if (params.batch != null) {
if (document.getElementById('input_images')) {
setVal('input_images', String(params.batch));
} else if (document.getElementById('input_batchsize')) {
setVal('input_batchsize', String(params.batch));
}
}
const loras = Array.isArray(params.loras) ? params.loras : [];
await applyPatch({ loras }, 'loras');
if (params.pack) {
setPackValue(params.pack, { flash: false });
}
if (params.persona) {
await applyPersonaForChat(params.persona, { quiet: true });
}
state.sessionExact = params.sessionExact && typeof params.sessionExact === 'object'
? { ...params.sessionExact }
: {};
state.lastPatch = params.lastPatch || null;
if (Array.isArray(params.genResults) && params.genResults.length) {
state.genResults = params.genResults.map((r, i) => ({
id: r.id || `var${i + 1}`,
label: r.label || `Вариант ${i + 1}`,
src: r.src || null,
patch: r.patch || null,
}));
state.selectedGenResultId = params.selectedGenResultId
|| state.genResults.find((r) => r.src)?.id
|| state.genResults[0]?.id
|| null;
const selected = state.genResults.find((r) => r.id === state.selectedGenResultId);
const gen = generateSlot();
if (gen && selected?.src) {
gen.src = selected.src;
}
} else {
clearGenResults();
}
syncBuildGenButton();
syncLiveParamsBar();
syncModeBadge();
renderLoraChips();
syncChipHighlight();
renderBoard();
return { restored: true };
} finally {
state.restoringChat = false;
}
}
function applyPersonaForChat(personaId, { quiet = false } = {}) {
const id = String(personaId || 'neutral').trim() || 'neutral';
return new Promise((resolve) => {
const sel = $('sa_persona');
if (sel && [...sel.options].some((o) => o.value === id)) {
sel.value = id;
}
if (!quiet) {
onPersonaChanged();
resolve();
return;
}
// Quiet: reload persona config/exact without chat spam or wiping sessionExact.
saveSettings();
if (typeof genericRequest !== 'function') {
resolve();
return;
}
const packKeep = $('sa_pack')?.value;
genericRequest(
'AssistentGetConfig',
{ persona: id },
(data) => {
applyConfigPayload(data, { applyDefaults: false });
if (sel && [...sel.options].some((o) => o.value === id)) {
sel.value = id;
}
if (packKeep) {
setPackValue(packKeep, { flash: false });
}
resolve();
},
0,
() => resolve(),
);
});
}
function persistChatsStore() {
try {
const chats = (state.chats || [])
.filter((c) => c && c.id && (c.id === state.activeChatId || (c.messages || []).length > 0))
.slice()
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
.slice(0, MAX_CHATS)
.map((c) => ({
id: c.id,
title: c.title || 'Новый чат',
createdAt: c.createdAt || Date.now(),
updatedAt: c.updatedAt || Date.now(),
messages: slimHistoryMessages(c.messages || []),
params: c.params || null,
}));
state.chats = chats;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
saveActiveChatToDisk();
} catch (e) {
console.warn('Assistent: persist chats failed', e);
try {
// Quota fallback: keep fewer / shorter chats.
const slim = (state.chats || [])
.filter((c) => c && c.id && (c.id === state.activeChatId || (c.messages || []).length > 0))
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
.slice(0, 12)
.map((c) => ({
...c,
messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
...m,
content: String(m.content || '').slice(0, 1500),
})),
}));
state.chats = slim;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
saveActiveChatToDisk();
} catch (e2) {
console.warn('Assistent: chats quota fallback failed', e2);
}
}
}
/** Mirrors the active chat onto the data volume (debounced inside SA.persist). */
function saveActiveChatToDisk() {
const persist = diskPersist();
if (!persist || !state.activeChatId) {
return;
}
const chat = findChat(state.activeChatId);
if (!chat || !(chat.messages || []).length) {
return;
}
persist.saveChat(chat);
}
function loadChatsStore() {
state.chats = [];
try {
const raw = localStorage.getItem(LS_CHATS);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.chats)) {
state.chats = parsed.chats.filter((c) => c && c.id);
}
}
} catch (e) { /* ignore */ }
migrateLegacyHistoryIntoChats();
}
/** Disk wins over localStorage — chats follow the data volume, not the browser. */
async function loadChatsFromDisk() {
const persist = diskPersist();
if (!persist) {
return;
}
let chats = null;
try {
chats = await persist.loadChats();
} catch (e) {
console.warn('Assistent: disk chats failed', e);
return;
}
if (!Array.isArray(chats) || !chats.length) {
return;
}
state.chats = chats.filter((c) => c && c.id).slice(0, MAX_CHATS);
try {
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: state.chats }));
} catch (e) { /* quota — disk is the source of truth anyway */ }
}
function migrateLegacyHistoryIntoChats() {
try {
const raw = localStorage.getItem(LS_HISTORY);
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.length) {
localStorage.removeItem(LS_HISTORY);
return;
}
const messages = slimHistoryMessages(parsed);
if (!messages.length) {
localStorage.removeItem(LS_HISTORY);
return;
}
const already = state.chats.some((c) =>
(c.messages || []).length === messages.length
&& (c.messages[0]?.content || '') === (messages[0]?.content || ''));
if (!already) {
state.chats.unshift({
id: chatUid(),
title: titleFromMessages(messages),
createdAt: Date.now() - 1000,
updatedAt: Date.now() - 1000,
messages,
params: null,
});
persistChatsStore();
}
localStorage.removeItem(LS_HISTORY);
} catch (e) {
try { localStorage.removeItem(LS_HISTORY); } catch (e2) { /* ignore */ }
}
}
function findChat(id) {
return (state.chats || []).find((c) => c.id === id) || null;
}
function saveActiveChatToStore({ dropEmpty = false } = {}) {
if (!state.activeChatId || state.restoringChat) {
return;
}
const chat = findChat(state.activeChatId);
if (!chat) {
return;
}
chat.messages = slimHistoryMessages(state.history);
chat.params = snapshotChatParams();
chat.updatedAt = Date.now();
chat.title = titleFromMessages(chat.messages);
if (dropEmpty && !chat.messages.length) {
state.chats = state.chats.filter((c) => c.id !== chat.id);
if (state.activeChatId === chat.id) {
state.activeChatId = null;
}
}
persistChatsStore();
}
function resetMessagesUi(emptyHint) {
const box = $('sa_messages');
if (!box) {
return;
}
box.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'sa-chat-empty';
empty.id = 'sa_chat_empty';
empty.innerHTML = emptyHint
|| '<div class="sa-chat-empty-title">Новый чат</div><div class="sa-chat-empty-hint">Параметры Generate остаются как сейчас.<br><strong>+</strong> — ещё один чат · <strong>История</strong> — вернуться к прошлому (с его параметрами).</div>';
box.appendChild(empty);
}
function renderHistoryIntoUi(messages) {
const box = $('sa_messages');
if (!box) {
return;
}
box.innerHTML = '';
const list = slimHistoryMessages(messages);
if (!list.length) {
resetMessagesUi();
return;
}
for (const m of list) {
if (m.role === 'user') {
appendMessage('user', m.content, null, null, { historical: true });
} else {
appendMessage('assistant', m.content, null, null, {
persona: m.persona ? { id: m.persona, title: m.persona } : null,
pack: m.pack,
historical: true,
});
}
}
}
function updateSessionLabel() {
const el = $('sa_session_label');
if (!el) {
return;
}
const chat = findChat(state.activeChatId);
el.textContent = chat?.title || 'Новый чат';
el.title = (chat?.title || 'Новый чат') + ' — клик: История';
}
function savedChatsCount() {
return (state.chats || []).filter((c) => (c.messages || []).length > 0).length;
}
function syncHistoryBadge() {
const btn = $('sa_btn_chats');
if (!btn) {
return;
}
const n = savedChatsCount();
btn.textContent = n > 0 ? `История (${n})` : 'История';
btn.title = n > 0
? `Сохранённых чатов: ${n}. Переключение восстанавливает параметры.`
: 'История чатов (пока пусто)';
}
function formatChatWhen(ts) {
if (!ts) {
return '';
}
try {
return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
} catch (e) {
return '';
}
}
function chatMatchesQuery(chat, q) {
if (!q) {
return true;
}
const title = String(chat?.title || '').toLowerCase();
if (title.includes(q)) {
return true;
}
const msgs = chat?.messages || [];
for (const m of msgs) {
if (String(m?.content || '').toLowerCase().includes(q)) {
return true;
}
}
return false;
}
function renderChatsList() {
const root = $('sa_chats_list');
if (!root) {
return;
}
root.innerHTML = '';
syncHistoryBadge();
const q = (state.chatsQuery || '').trim().toLowerCase();
let chats = (state.chats || [])
.slice()
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
.filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
if (q) {
const local = chats.filter((c) => chatMatchesQuery(c, q));
const seen = new Set(local.map((c) => c.id));
const extra = (state.chatsSearchHits || []).filter((h) => h && h.id && !seen.has(h.id));
chats = local.concat(extra);
}
if (!chats.length) {
root.innerHTML = q
? '<div class="sa-chats-empty">Ничего не нашлось.</div>'
: '<div class="sa-chats-empty">Пока пусто. Напиши что-нибудь в чат — он появится здесь. Переключение вернёт и сообщения, и параметры Generate.</div>';
return;
}
for (const c of chats) {
const row = document.createElement('div');
row.className = 'sa-chat-row' + (c.id === state.activeChatId ? ' sa-chat-row-active' : '');
row.dataset.id = c.id;
const n = (c.messages || []).length || Number(c.messages_count) || 0;
const bits = [];
if (c.params?.width && c.params?.height) {
bits.push(`${c.params.width}×${c.params.height}`);
}
if (c.params?.steps != null) {
bits.push(`steps ${c.params.steps}`);
}
if (c.params?.cfg != null) {
bits.push(`cfg ${c.params.cfg}`);
}
if (Array.isArray(c.params?.loras) && c.params.loras.length) {
bits.push(`LoRA ${c.params.loras.length}`);
}
const noParams = !c.params ? ' · без снимка params' : '';
row.innerHTML = `<button type="button" class="sa-chat-row-main" data-open="1"><span class="sa-chat-row-title">${escapeHtml(c.title || 'Чат')}</span><span class="sa-chat-row-meta">${escapeHtml([formatChatWhen(c.updatedAt), `${n} сообщ.`, bits.join(' · ')].filter(Boolean).join(' · ') + noParams)}</span></button><button type="button" class="sa-chat-row-del" data-del="1" title="Удалить чат">×</button>`;
root.appendChild(row);
}
}
function setChatsPanelOpen(open) {
state.chatsPanelOpen = !!open;
const panel = $('sa_chats_panel');
const btn = $('sa_btn_chats');
if (panel) {
panel.hidden = !state.chatsPanelOpen;
}
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
if (state.chatsPanelOpen) {
saveActiveChatToStore();
const search = $('sa_chats_search');
if (search) {
search.value = state.chatsQuery || '';
search.focus();
}
renderChatsList();
}
}
async function startNewChat({ saveCurrent = true, force = false } = {}) {
if (!force && (state.busy || state.generating)) {
setStatus('Занято — дождись конца ответа или Стоп');
return;
}
if (force) {
// Drop in-flight reply so it cannot land in the new chat.
abortInFlightWork({ status: '' });
}
setChatsPanelOpen(false);
if (saveCurrent) {
saveActiveChatToStore({ dropEmpty: true });
}
state.sessionExact = {};
state.lastUserParamIntent = false;
state.pendingSilentGen = false;
state.lastPatch = null;
clearGenResults();
const chat = {
id: chatUid(),
title: 'Новый чат',
createdAt: Date.now(),
updatedAt: Date.now(),
messages: [],
params: snapshotChatParams(),
};
state.chats.unshift(chat);
state.activeChatId = chat.id;
state.history = [];
state.critiqueHopUsed = false;
state.visionHopUsed = false;
state.packUserTouched = false;
state.pendingPersonaNote = null;
if (state.streamEl) {
try { state.streamEl.remove(); } catch (e) { /* ignore */ }
state.streamEl = null;
}
syncBuildGenButton();
resetMessagesUi();
persistChatsStore();
updateSessionLabel();
renderBoard();
syncHistoryBadge();
renderChatsList();
setStatus('Новый чат — параметры Generate как сейчас');
maybeWelcome();
}
async function switchToChat(id) {
if (!id || id === state.activeChatId) {
setChatsPanelOpen(false);
return;
}
if (state.busy || state.generating) {
setStatus('Занято — нельзя сменить чат сейчас');
return;
}
saveActiveChatToStore({ dropEmpty: true });
let chat = findChat(id);
if (!chat || !(chat.messages || []).length) {
try {
const full = await diskPersist()?.getChat?.(id);
if (full) {
const idx = (state.chats || []).findIndex((c) => c.id === id);
if (idx >= 0) {
state.chats[idx] = full;
} else {
state.chats.unshift(full);
}
chat = full;
}
} catch (e) {
console.warn('Assistent: getChat failed', id, e);
}
}
if (!chat) {
setStatus('Чат не найден');
return;
}
state.activeChatId = chat.id;
state.history = slimHistoryMessages(chat.messages);
state.critiqueHopUsed = false;
state.visionHopUsed = false;
state.packUserTouched = false;
state.pendingPersonaNote = null;
state.pendingSilentGen = false;
state.lastUserParamIntent = false;
if (state.streamEl) {
try { state.streamEl.remove(); } catch (e) { /* ignore */ }
state.streamEl = null;
}
renderHistoryIntoUi(state.history);
const result = await restoreChatParams(chat.params);
updateSessionLabel();
syncHistoryBadge();
renderChatsList();
setChatsPanelOpen(false);
setView('chat');
if (result?.restored) {
setStatus(`Чат «${chat.title}» · параметры восстановлены`);
} else {
setStatus(`Чат «${chat.title}» · снимок параметров отсутствует — Generate не менялся`);
}
}
function deleteChat(id) {
if (!id) {
return;
}
const wasActive = id === state.activeChatId;
state.chats = state.chats.filter((c) => c.id !== id);
if (wasActive) {
state.activeChatId = null;
}
diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
persistChatsStore();
syncHistoryBadge();
if (wasActive) {
startNewChat({ saveCurrent: false, force: true });
} else {
renderChatsList();
}
}
async function initChatSessions() {
loadChatsStore();
await loadChatsFromDisk();
// Always open a fresh chat on startup; past chats stay in History.
startNewChat({ saveCurrent: false, force: true });
syncHistoryBadge();
renderChatsList();
}
function persistHistory() {
if (state.restoringChat) {
return;
}
if (!state.activeChatId) {
const chat = {
id: chatUid(),
title: 'Новый чат',
createdAt: Date.now(),
updatedAt: Date.now(),
messages: [],
params: snapshotChatParams(),
};
state.chats.unshift(chat);
state.activeChatId = chat.id;
}
const chat = findChat(state.activeChatId);
if (!chat) {
return;
}
chat.messages = slimHistoryMessages(state.history);
chat.params = snapshotChatParams();
chat.updatedAt = Date.now();
chat.title = titleFromMessages(chat.messages);
persistChatsStore();
updateSessionLabel();
syncHistoryBadge();
}
function restoreHistory() {
// Replaced by initChatSessions — kept as no-op for safety.
}
function clearPersistedHistory() {
if (state.activeChatId) {
const chat = findChat(state.activeChatId);
if (chat) {
chat.messages = [];
chat.title = 'Новый чат';
chat.params = snapshotChatParams();
chat.updatedAt = Date.now();
}
persistChatsStore();
}
updateSessionLabel();
renderChatsList();
}
function clearChatHistory() {
abortInFlightWork({ status: '' });
state.history = [];
state.critiqueHopUsed = false;
state.visionHopUsed = false;
state.packUserTouched = false;
state.pendingPersonaNote = null;
state.sessionExact = {};
state.lastUserParamIntent = false;
state.pendingSilentGen = false;
state.lastPatch = null;
clearGenResults();
syncBuildGenButton();
clearPersistedHistory();
resetMessagesUi('<div class="sa-chat-empty-title">Чат очищен</div><div class="sa-chat-empty-hint">Сообщения сброшены. Параметры Generate на месте. <strong>+</strong> — новый чат в Историю, <strong>История</strong> — прошлые диалоги.</div>');
setStatus('Чат очищен');
updateSessionLabel();
syncHistoryBadge();
renderBoard();
}
function hideSlashMenu() {
const menu = $('sa_slash_menu');
if (menu) {
menu.hidden = true;
menu.innerHTML = '';
}
state.slashIndex = 0;
}
function slashMatches(text) {
const t = String(text || '');
if (!t.startsWith('/')) {
return [];
}
const q = t.toLowerCase();
return SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q) || q === '/' || c.cmd.toLowerCase().includes(q.slice(1)));
}
function renderSlashMenu(items) {
const menu = $('sa_slash_menu');
if (!menu) {
return;
}
if (!items.length) {
hideSlashMenu();
return;
}
menu.hidden = false;
menu.innerHTML = '';
state.slashIndex = Math.max(0, Math.min(state.slashIndex, items.length - 1));
items.forEach((item, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sa-slash-item' + (i === state.slashIndex ? ' sa-slash-active' : '');
btn.setAttribute('role', 'option');
btn.innerHTML = `<code>${escapeHtml(item.cmd.trim())}</code> — ${escapeHtml(item.hint)}`;
btn.addEventListener('mousedown', (e) => {
e.preventDefault();
applySlashPick(item);
});
menu.appendChild(btn);
});
}
function applySlashPick(item) {
const input = $('sa_input');
if (!input || !item) {
return;
}
input.value = item.cmd;
hideSlashMenu();
input.focus();
const pos = input.value.length;
input.setSelectionRange(pos, pos);
}
function updateSlashMenuFromInput() {
const text = $('sa_input')?.value || '';
if (!text.startsWith('/') || text.includes('\n') || /\s/.test(text.trim().slice(1)) && !text.endsWith(' ')) {
// show while typing command token only
const token = text.split(/\s/)[0] || '';
if (!token.startsWith('/') || (text.includes(' ') && !SLASH_COMMANDS.some((c) => c.cmd.startsWith(token)))) {
if (!(token.startsWith('/') && !text.includes(' '))) {
hideSlashMenu();
return;
}
}
}
const token = (text.split(/\s/)[0] || '');
if (!token.startsWith('/') || text.indexOf(' ') > 0) {
hideSlashMenu();
return;
}
renderSlashMenu(slashMatches(token));
}
function onPersonaChanged() {
const id = $('sa_persona')?.value || 'neutral';
state.sessionExact = {};
state.lastUserParamIntent = false;
saveSettings();
loadConfig(id, (data) => {
const title = data?.personas?.find((p) => p.id === id)?.title
|| (state.personas || []).find((p) => p.id === id)?.title
|| id;
if (data?.personas) {
state.personas = data.personas;
}
appendSystemNote(`Тон → ${title}`);
state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
if (data?.assistant?.default_pack && $('sa_pack') && !state.packUserTouched) {
const packId = data.assistant.default_pack;
if ([...($('sa_pack').options || [])].some((o) => o.value === packId)) {
$('sa_pack').value = packId;
}
}
fillEmptyParamsFromExact();
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
if (state.view === 'settings') {
if (state.settingsTab === 'user') {
refreshUserPrefs();
}
if (state.settingsTab === 'craft') {
renderMemoryList();
}
if (state.settingsTab === 'more') {
fillKnobsFromConfig(data);
}
}
});
}
function countPromptImages() {
try {
const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
if (!box) {
return 0;
}
const text = box.value || '';
const matches = text.match(/<image(?:\s|\/|>)/gi) || text.match(/data:image\//gi);
return matches ? matches.length : 0;
} catch (e) {
return 0;
}
}
function triggerChangeForEl(el) {
if (!el) {
return;
}
if (typeof triggerChangeFor === 'function') {
triggerChangeFor(el);
return;
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function openInitImageGroup() {
try {
const initEl = document.getElementById('input_initimage');
if (initEl && typeof toggleGroupOpen === 'function') {
toggleGroupOpen(initEl, true);
}
} catch (e) { /* ignore */ }
const toggler = document.getElementById('input_group_content_initimage_toggle');
if (toggler) {
toggler.checked = true;
triggerChangeForEl(toggler);
}
}
function hasFileParam(id) {
const el = document.getElementById(id);
return !!(el && el.files && el.files.length > 0);
}
function clearFileParam(id) {
const el = document.getElementById(id);
if (!el) {
return false;
}
try {
el.value = '';
if (el.files && typeof DataTransfer !== 'undefined') {
el.files = new DataTransfer().files;
}
} catch (e) { /* ignore */ }
triggerChangeForEl(el);
return true;
}
function guessImageMime(src) {
const s = String(src || '');
if (s.startsWith('data:image/')) {
const m = s.match(/^data:(image\/[a-z0-9.+-]+)/i);
return (m && m[1]) || 'image/png';
}
const path = s.split('?')[0];
const ext = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') {
return 'image/jpeg';
}
if (ext === 'webp') {
return 'image/webp';
}
if (ext === 'gif') {
return 'image/gif';
}
return 'image/png';
}
async function srcToBlob(src) {
if (!src) {
return null;
}
const cleaned = String(src).trim().split(/\s+/)[0];
if (cleaned.startsWith('data:') || cleaned.startsWith('/') || cleaned.startsWith('View/') || cleaned.startsWith('http')) {
try {
const resp = await fetch(cleaned);
return await resp.blob();
} catch (e) {
console.warn('Assistent: fetch blob failed', e);
}
}
return await new Promise((resolve) => {
const tmpImg = new Image();
tmpImg.crossOrigin = 'Anonymous';
tmpImg.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = tmpImg.naturalWidth;
canvas.height = tmpImg.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(tmpImg, 0, 0);
canvas.toBlob((blob) => resolve(blob), 'image/png');
} catch (e) {
resolve(null);
}
};
tmpImg.onerror = () => resolve(null);
tmpImg.src = cleaned;
});
}
async function setFileParamFromSrc(paramId, src, { filename = 'assistent.png' } = {}) {
const el = document.getElementById(paramId);
if (!el) {
setStatus(`Missing ${paramId} on Generate tab`);
return false;
}
const blob = await srcToBlob(src);
if (!blob) {
setStatus('Could not load image for init/mask');
return false;
}
const mime = blob.type || guessImageMime(src);
const file = new File([blob], filename, { type: mime });
const container = new DataTransfer();
container.items.add(file);
el.files = container.files;
triggerChangeForEl(el);
openInitImageGroup();
return true;
}
async function setInitFromSrc(src) {
const ok = await setFileParamFromSrc('input_initimage', src, { filename: 'assistent_init.png' });
if (ok) {
setStatus('Init Image set');
}
return ok;
}
async function setMaskFromSrc(src) {
const ok = await setFileParamFromSrc('input_maskimage', src, { filename: 'assistent_mask.png' });
if (ok) {
setStatus('Mask Image set (white = edit)');
}
return ok;
}
function clearInitAndMask() {
clearFileParam('input_initimage');
clearFileParam('input_maskimage');
const toggler = document.getElementById('input_group_content_initimage_toggle');
if (toggler) {
toggler.checked = false;
triggerChangeForEl(toggler);
}
setStatus('Init Image + Mask cleared');
}
function readInitContext() {
const creativityRaw = val('input_initimagecreativity');
const creativity = creativityRaw === '' ? null : parseFloat(creativityRaw);
return {
has_init_image: hasFileParam('input_initimage'),
has_mask_image: hasFileParam('input_maskimage'),
init_creativity: Number.isFinite(creativity) ? creativity : null,
mask_blur: parseFloat(val('input_maskblur') || '') || null,
mask_grow: parseInt(val('input_maskgrow') || val('input_maskshrinkgrow') || '', 10) || null,
init_group_on: !!(document.getElementById('input_group_content_initimage_toggle')?.checked),
};
}
function slimPromptForContext(raw) {
let s = String(raw || '');
s = s.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '[image omitted]');
s = s.replace(/<image\b[^>]*>[\s\S]*?<\/image>/gi, '[image omitted]');
s = s.replace(/<img\b[^>]*>/gi, '[image omitted]');
if (s.length > CONTEXT_PROMPT_MAX) {
s = s.slice(0, CONTEXT_PROMPT_MAX) + '…';
}
return s;
}
function collectLiveContext() {
const inv = state.inventory || {};
const initCtx = readInitContext();
const ctx = {
architecture_ok: isKreaSelected(),
checkpoint: null,
prompt: slimPromptForContext(val('alt_prompt_textbox') || val('input_prompt') || ''),
negative: slimPromptForContext(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || ''),
width: parseInt(val('input_width') || '0', 10) || null,
height: parseInt(val('input_height') || '0', 10) || null,
steps: parseInt(val('input_steps') || '0', 10) || null,
cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
seed: val('input_seed') || null,
sampler: val('input_sampler') || val('input_samplerate') || null,
scheduler: val('input_scheduler') || null,
batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
prompt_image_count: countPromptImages(),
selected_loras: [],
available_loras: [],
available_checkpoints: slimInventoryCheckpoints(inv.checkpoints || [], 8),
wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 20),
inventory_at: inv.inventory_at || null,
has_vision_image: visionReadySlots().length > 0,
image_slots: slotCatalog(),
attached_slot_ids: attachableSlots().map((s) => s.id),
gen_results: (state.genResults || []).map((r) => ({
id: r.id,
label: r.label,
has_image: !!r.src,
selected: r.id === state.selectedGenResultId,
})),
selected_gen_result: state.selectedGenResultId || null,
has_civitai_key: !!inv.has_civitai_key,
auto_apply: !!$('sa_auto_apply')?.checked,
auto_generate: !!$('sa_auto_generate')?.checked,
persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral',
model_cards: [],
user_prefs_count: 0,
...initCtx,
};
{
const slim = slimInventoryLoras(inv.loras || [], INVENTORY_PROMPT_NAMES);
ctx.available_loras = slim;
if ((inv.loras || []).length > slim.length) {
ctx.available_loras_truncated = true;
ctx.available_loras_total = (inv.loras || []).length;
}
}
try {
const model = resolveCurrentCheckpoint();
if (model.name || model.architecture) {
ctx.checkpoint = {
name: model.name || model.title || null,
architecture: model.architecture || model.compat_class || model.class || null,
title: model.title || null,
};
}
} catch (e) { /* ignore */ }
try {
if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
const byName = new Map();
for (const l of inv.loras || []) {
if (l?.name) {
byName.set(String(l.name).toLowerCase(), l);
}
}
ctx.selected_loras = loraHelper.selected.map((l) => {
const name = l.name || l;
const invRow = byName.get(String(name).toLowerCase()) || {};
const out = {
name,
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[name]) || invRow.default_weight || 1,
};
if (invRow.trigger_phrase) {
out.trigger_phrase = invRow.trigger_phrase;
}
if (Array.isArray(invRow.triggers) && invRow.triggers.length) {
out.triggers = invRow.triggers.slice(0, 8);
}
if (invRow.blurb) {
out.blurb = invRow.blurb;
}
return out;
});
// selected_loras = enabled; do not also emit enabled_loras (duplicate).
}
} catch (e) { /* ignore */ }
// Recommendation cards: checkpoint + selected LoRAs only when they add beyond inventory.
const cardKeys = [];
const seenCard = new Set();
const addKey = (kind, name) => {
if (!kind || !name) {
return;
}
const key = `${kind}:${name}`;
if (seenCard.has(key)) {
return;
}
seenCard.add(key);
cardKeys.push({ kind, name });
};
if (ctx.checkpoint?.name) {
addKey('checkpoint', ctx.checkpoint.name);
}
for (const l of ctx.selected_loras || []) {
if (l?.name) {
addKey('lora', l.name);
}
}
for (const k of cardKeys) {
const cached = state.modelCards[`${k.kind}:${k.name}`];
if (!cached) {
continue;
}
const slim = slimCardForContext(cached);
if (!slim) {
continue;
}
if (k.kind === 'lora') {
const sel = (ctx.selected_loras || []).find(
(l) => String(l.name || '').toLowerCase() === String(k.name).toLowerCase(),
);
const inventoryRich = !!(sel && (sel.triggers?.length || sel.trigger_phrase || sel.blurb));
const cardExtra = !!(slim.when || slim.avoid || slim.prompt_hint || slim.notes);
if (inventoryRich && !cardExtra) {
continue;
}
}
ctx.model_cards.push(slim);
}
// Fallback if inventory empty
if (!ctx.available_loras.length) {
try {
const models = (typeof allModels !== 'undefined' && allModels) || (typeof model_list !== 'undefined' && model_list) || [];
const list = Array.isArray(models) ? models : Object.values(models || {});
for (const m of list) {
if (!m) {
continue;
}
const folder = `${m.folder || m.path || ''}`;
const isLora = /lora/i.test(m.category || m.type || '') || (m.name && String(m.name).toLowerCase().includes('lora'));
const inLoraFolder = /lora/i.test(folder);
if (!(isLora || inLoraFolder)) {
continue;
}
ctx.available_loras.push({
name: m.name || m.title,
title: m.title || m.name,
trigger_phrase: m.trigger_phrase || m.trigger || (m.metadata && (m.metadata.trigger_phrase || m.metadata.trigger)) || null,
architecture: m.architecture || null,
});
}
if (ctx.available_loras.length > INVENTORY_PROMPT_NAMES) {
ctx.available_loras = ctx.available_loras.slice(0, INVENTORY_PROMPT_NAMES);
}
} catch (e) { /* ignore */ }
}
try {
const model = ctx.checkpoint || {};
const blob = `${model.name || ''} ${model.title || ''}`.toLowerCase();
const hasRaw = /\braw\b/.test(blob);
const hasTurbo = /\bturbo\b/.test(blob);
const profile = hasRaw && !hasTurbo ? 'raw' : 'turbo';
ctx.krea_profile = profile;
const defaults = mergedGenerationDefaults(profile);
// Only send recommended_params when live UI differs from Exact-backed defaults
// (Exact itself is already in the system prompt).
const rec = {
steps: defaults.steps ?? 8,
cfg: defaults.cfg ?? 1,
sigma_shift: defaults.sigma_shift ?? 1.15,
};
if (defaults.aspect) {
rec.aspect = defaults.aspect;
}
const liveDiffers = (ctx.steps != null && ctx.steps !== rec.steps)
|| (ctx.cfg != null && ctx.cfg !== rec.cfg)
|| (ctx.sigma_shift != null && ctx.sigma_shift !== rec.sigma_shift);
if (liveDiffers) {
ctx.recommended_params = rec;
}
} catch (e) {
ctx.krea_profile = 'turbo';
}
ctx.session_exact = state.sessionExact && Object.keys(state.sessionExact).length
? { ...state.sessionExact }
: undefined;
// Exact KV is already in the system prompt — do not duplicate the full blob into live context.
if (!ctx.session_exact) {
delete ctx.session_exact;
}
return ctx;
}
function slimCardForContext(card) {
if (!card || typeof card !== 'object') {
return null;
}
const out = {
kind: card.kind || null,
name: card.name || null,
triggers: Array.isArray(card.triggers) ? card.triggers.slice(0, 8) : undefined,
weight: card.weight != null ? card.weight : undefined,
when: card.when ? String(card.when).slice(0, 160) : undefined,
avoid: card.avoid ? String(card.avoid).slice(0, 120) : undefined,
prompt_hint: card.prompt_hint ? String(card.prompt_hint).slice(0, 160) : undefined,
notes: card.notes ? String(card.notes).slice(0, 200) : undefined,
};
const clean = {};
for (const [k, v] of Object.entries(out)) {
if (v != null && v !== '') {
clean[k] = v;
}
}
return clean;
}
function summarizeTaste() {
const t = state.taste || {};
if (!(t.styles?.length || t.likes?.length || t.avoid?.length || t.notes)) {
return null;
}
return {
styles: (t.styles || []).slice(0, 8),
likes: (t.likes || []).slice(0, 10),
avoid: (t.avoid || []).slice(0, 8),
notes: t.notes ? String(t.notes).slice(0, 240) : undefined,
};
}
function slimInventoryLoras(list, limit) {
const selected = new Set();
try {
if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
for (const l of loraHelper.selected) {
selected.add(String(l.name || l || '').toLowerCase());
}
}
} catch (e) { /* ignore */ }
const namesCap = Math.min(limit || INVENTORY_PROMPT_NAMES, INVENTORY_PROMPT_NAMES);
const richCap = Math.max(4, Math.min(INVENTORY_PROMPT_RICH, namesCap));
const rows = (list || []).map((l) => {
const sel = selected.has(String(l.name || '').toLowerCase());
const hasCard = !!l.has_card;
const krea = !!l.krea_likely;
const blurb = l.blurb || l.usage_hint || null;
return {
name: l.name,
title: l.title || l.name,
trigger_phrase: l.trigger_phrase || null,
triggers: Array.isArray(l.triggers) ? l.triggers.slice(0, 8) : undefined,
architecture: l.architecture || null,
compat_class: l.compat_class || null,
has_card: hasCard,
krea_likely: krea,
blurb,
default_weight: l.default_weight || undefined,
tags: Array.isArray(l.tags) ? l.tags.slice(0, 6) : undefined,
_score: (sel ? 1000 : 0) + (hasCard ? 200 : 0) + (krea ? 50 : 0) + (blurb ? 10 : 0),
};
});
rows.sort((a, b) => b._score - a._score || String(a.name).localeCompare(String(b.name)));
// Rich: enabled + top krea/card (triggers/blurb). Rest: name (+ krea_likely) only.
let richUsed = 0;
const out = [];
for (const row of rows) {
if (out.length >= namesCap) {
break;
}
const sel = selected.has(String(row.name || '').toLowerCase());
let wantRich = sel;
if (!wantRich && richUsed < richCap && (row.krea_likely || row.has_card || row.blurb)) {
wantRich = true;
}
if (wantRich) {
const rich = { name: row.name, title: row.title };
if (row.trigger_phrase) {
rich.trigger_phrase = row.trigger_phrase;
}
if (row.triggers) {
rich.triggers = row.triggers;
}
if (row.krea_likely) {
rich.krea_likely = true;
}
if (row.has_card) {
rich.has_card = true;
}
if (row.blurb) {
rich.blurb = row.blurb;
}
if (row.default_weight) {
rich.default_weight = row.default_weight;
}
if (row.architecture) {
rich.architecture = row.architecture;
}
out.push(rich);
if (!sel) {
richUsed++;
}
} else {
const nameOnly = { name: row.name };
if (row.krea_likely) {
nameOnly.krea_likely = true;
}
out.push(nameOnly);
}
}
return out;
}
function slimInventoryCheckpoints(list, limit) {
const rows = (list || []).slice();
rows.sort((a, b) => ((b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0)) || ((b.has_card ? 1 : 0) - (a.has_card ? 1 : 0)) || String(a.name).localeCompare(String(b.name)));
return rows.slice(0, limit || 8).map((c) => {
const out = {
name: c.name,
title: c.title || c.name,
};
if (c.architecture) {
out.architecture = c.architecture;
}
if (c.krea_likely) {
out.krea_likely = true;
}
if (c.has_card) {
out.has_card = true;
}
return out;
});
}
function loadTaste() {
try {
const raw = localStorage.getItem(LS_TASTE);
if (!raw) {
return;
}
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
state.taste = {
styles: Array.isArray(parsed.styles) ? parsed.styles.slice(0, 12) : [],
likes: Array.isArray(parsed.likes) ? parsed.likes.slice(0, 16) : [],
avoid: Array.isArray(parsed.avoid) ? parsed.avoid.slice(0, 12) : [],
notes: String(parsed.notes || '').slice(0, 400),
updated: parsed.updated || 0,
};
}
} catch (e) { /* ignore */ }
}
function saveTaste() {
try {
localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
} catch (e) { /* ignore */ }
saveTasteToServerDebounced();
}
function pushUnique(arr, value, max) {
const v = String(value || '').trim();
if (!v || v.length < 2) {
return;
}
const lower = v.toLowerCase();
const next = (arr || []).filter((x) => String(x).toLowerCase() !== lower);
next.unshift(v.slice(0, 80));
return next.slice(0, max);
}
function updateTasteFromPatch(patch, userText) {
if (!patch) {
return;
}
const taste = state.taste || { styles: [], likes: [], avoid: [], notes: '' };
if (Array.isArray(patch.loras)) {
for (const l of patch.loras) {
const name = l?.name || l;
if (name) {
taste.likes = pushUnique(taste.likes, name, 16);
}
}
}
const aspect = patch.aspect || null;
if (aspect) {
taste.styles = pushUnique(taste.styles, `aspect ${aspect}`, 12);
}
if (patch.creativity) {
taste.styles = pushUnique(taste.styles, `creativity:${patch.creativity}`, 12);
}
const ut = String(userText || '').toLowerCase();
if (/фото|photo|photoreal|реализм|film grain/.test(ut)) {
taste.styles = pushUnique(taste.styles, 'photoreal / film', 12);
}
if (/аниме|anime|illustration|иллюстр/.test(ut)) {
taste.styles = pushUnique(taste.styles, 'illustration / anime', 12);
}
if (/без\s+3d|не\s+3d|no\s+3d|не\s+render/.test(ut)) {
taste.avoid = pushUnique(taste.avoid, '3D render look', 12);
}
taste.updated = Date.now();
state.taste = taste;
saveTaste();
syncTasteHintsToUserPrefs(taste);
}
function syncTasteHintsToUserPrefs(taste) {
if (typeof genericRequest !== 'function' || !taste) {
return;
}
const upsert = (key, text) => {
if (!text) {
return;
}
genericRequest(
'AssistentUpsertUserPref',
{ key, text: String(text).slice(0, 240), scope: 'global', source: 'migrated_taste', pinned: false },
() => {},
0,
() => {},
);
};
if (taste.avoid?.[0]) {
upsert('avoid_hint', `Avoid: ${taste.avoid.slice(0, 4).join('; ')}`);
}
if (taste.styles?.[0]) {
upsert('style_hint', `Styles: ${taste.styles.slice(0, 4).join('; ')}`);
}
if (taste.likes?.[0]) {
upsert('like_hint', `Often uses: ${taste.likes.slice(0, 4).join('; ')}`);
}
}
// Fallback key list — only used if assistent.patch.js failed to load.
const FALLBACK_PATCH_KEYS = [
'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise',
'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack',
'memories', 'user_prefs', 'variants',
];
function isPatchObject(obj) {
if (window.SA && typeof SA.isPatchObject === 'function') {
return SA.isPatchObject(obj);
}
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.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));
}
function extractCardJson(text) {
if (!text) {
return null;
}
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
let last = null;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isCardObject(obj)) {
last = obj;
}
} catch (e) { /* ignore */ }
}
if (last) {
return last;
}
try {
const obj = JSON.parse(text.trim());
return isCardObject(obj) ? obj : null;
} catch (e) {
return null;
}
}
function extractPatch(text) {
if (window.SA && typeof SA.extractPatch === 'function') {
return SA.extractPatch(text);
}
if (!text) {
return { prose: text || '', patch: null };
}
const re = /```(?:json)?\s*([\s\S]*?)```/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 = obj;
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
}
} catch (e) { /* not json */ }
}
return { prose, patch: lastPatch };
}
function normalizeAspect(raw) {
if (raw == null) {
return null;
}
let s = String(raw).trim().toLowerCase().replace(/\s+/g, '');
if (!s) {
return null;
}
if (s === 'square') {
s = '1:1';
} else if (s === 'portrait' || s === 'vert') {
s = '2:3';
} else if (s === 'landscape' || s === 'horiz') {
s = '16:9';
} else if (s === 'cinematic' || s === 'ultrawide') {
s = '2.35:1';
}
return ASPECT_TABLE[s] ? s : null;
}
function sizeFromAspect(aspect) {
const key = normalizeAspect(aspect);
return key ? ASPECT_TABLE[key] : null;
}
function guessAspectFromSize(w, h) {
const width = parseInt(w, 10);
const height = parseInt(h, 10);
if (!width || !height) {
return null;
}
let best = null;
let bestDist = Infinity;
for (const [key, [aw, ah]] of Object.entries(ASPECT_TABLE)) {
const dist = Math.abs(width / height - aw / ah) + Math.abs(width - aw) / 4000 + Math.abs(height - ah) / 4000;
if (dist < bestDist) {
bestDist = dist;
best = key;
}
}
return bestDist < 0.12 ? best : null;
}
function clearPromptImagesInBox() {
const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
if (!box) {
return false;
}
const before = box.value || '';
const next = before
.replace(/<image\b[^>]*>[\s\S]*?<\/image>/gi, '')
.replace(/<image\b[^>]*\/?>/gi, '')
.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
if (next === before.trim()) {
return false;
}
box.value = next;
box.dispatchEvent(new Event('input', { bubbles: true }));
box.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
function clearPatchBlocksOnly() {
document.querySelectorAll('#sa_messages .sa-patch, #sa_messages .sa-patch-stale, #sa_messages .sa-civitai-list').forEach((el) => el.remove());
state.lastPatch = null;
syncBuildGenButton();
setStatus('Патчи убраны из чата');
}
function toggleMoreMenu(menuId, btnId) {
const menu = $(menuId);
const btn = $(btnId);
if (!menu) {
return;
}
const open = menu.hidden;
document.querySelectorAll('.sa-more-menu').forEach((m) => {
m.hidden = true;
});
document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
if (open) {
menu.hidden = false;
btn?.setAttribute('aria-expanded', 'true');
}
}
function closeAllMoreMenus() {
document.querySelectorAll('.sa-more-menu').forEach((m) => {
m.hidden = true;
});
document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
}
function setPackValue(packName, { flash, user } = {}) {
const pack = $('sa_pack');
if (!pack || !packName) {
return false;
}
const resolved = PACK_ALIASES[String(packName).trim()] || String(packName).trim();
if (![...pack.options].some((o) => o.value === resolved)) {
return false;
}
if (pack.value !== resolved) {
pack.value = resolved;
saveSettings();
}
if (user) {
state.packUserTouched = true;
}
if (flash) {
pack.classList.add('sa-pack-flash');
setTimeout(() => pack.classList.remove('sa-pack-flash'), 900);
}
syncModeBadge();
return true;
}
function autoSelectPack(text) {
if (state.packUserTouched) {
return null;
}
// Комбайн «Обычный» сам выбирает поведение — не переключаем pack.
const cur = $('sa_pack')?.value || defaultPackId();
if (cur === 'ordinary') {
return null;
}
const t = String(text || '').toLowerCase();
if (!t.trim()) {
return null;
}
// Param / aspect asks must leave a stuck critique_image pack from auto-critique.
if (userTextMentionsParams(t) || parseAspectFromUserText(t)) {
return cur === 'critique_image' || cur === 'describe_ref' ? 'ordinary' : 'form_params';
}
if (cyrTokenRe('поправь|исправь|перепиши|улучши').test(t)
|| /\b(fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
return 'write_prompt';
}
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';
}
// 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)\b/i.test(t)
|| cyrTokenRe('замажь|закрась|руки|лицо|маск[а-яё]*').test(t)
|| /init\s*image/i.test(t)) {
return 'inpaint_edit';
}
// Any non-critique follow-up after auto-critique should leave critique mode.
if (cur === 'critique_image') {
return 'ordinary';
}
if (/\b(moodboard|compose|scene)\b/i.test(t)
|| cyrTokenRe('сцен[а-яё]*|атмосфер[а-яё]*|мизансцен[а-яё]*').test(t)) {
return 'compose_scene';
}
return 'write_prompt';
}
function restoreDefaultPackAfterHop() {
if (state.packUserTouched) {
return;
}
const cur = $('sa_pack')?.value || '';
if (cur === 'critique_image' || cur === 'describe_ref') {
setPackValue(defaultPackId(), { flash: true });
}
}
function patchHasGenTrigger(patch) {
if (!patch) {
return false;
}
if (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')) {
return true;
}
return (
patch.prompt != null ||
patch.loras ||
patch.width != null ||
patch.height != null ||
patch.aspect != null ||
patch.steps != null ||
patch.cfg != null ||
patch.seed != null ||
patch.sigma_shift != null ||
patch.images != null ||
patch.batch != null ||
patch.vary === true ||
(Array.isArray(patch.variants) && patch.variants.length > 0) ||
patch.use_init_image ||
patch.clear_init_image ||
patch.init_creativity != null ||
patch.denoise != null ||
patch.use_mask_image ||
patch.clear_mask_image ||
patch.clear_prompt_images
);
}
async function applyPatch(patch, which) {
if (!patch) {
return;
}
const doPrompt = !which || which === 'all' || which === 'prompt';
const doLoras = !which || which === 'all' || which === 'loras';
const doParams = !which || which === 'all' || which === 'size' || which === 'params';
const doInit = !which || which === 'all' || which === 'params' || which === 'init';
if (patch.pack) {
setPackValue(patch.pack, { flash: true });
}
if (doPrompt && patch.clear_prompt_images) {
clearPromptImagesInBox();
}
if (doPrompt && patch.prompt != null) {
const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
if (box) {
box.value = patch.prompt;
box.dispatchEvent(new Event('input', { bubbles: true }));
box.dispatchEvent(new Event('change', { bubbles: true }));
}
if (patch.negative != null) {
setVal('input_negativeprompt', patch.negative);
}
if (Array.isArray(patch.loras)) {
for (const l of patch.loras) {
const triggers = l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []);
for (const t of triggers) {
if (t && box && box.value && !box.value.includes(t)) {
box.value = `${box.value.trim()}, ${t}`;
box.dispatchEvent(new Event('input', { bubbles: true }));
}
}
}
}
}
if (doLoras && Array.isArray(patch.loras) && typeof loraHelper !== 'undefined' && loraHelper) {
try {
if (typeof loraHelper.clearLoras === 'function') {
loraHelper.clearLoras();
}
} catch (e) { /* ignore */ }
for (const l of patch.loras) {
const name = l.name;
if (!name) {
continue;
}
try {
if (typeof loraHelper.selectLora === 'function') {
loraHelper.selectLora(name);
}
if (loraHelper.loraWeightPref && l.weight != null) {
loraHelper.loraWeightPref[name] = l.weight;
}
} catch (e) {
console.warn('Assistent: selectLora failed', name, e);
}
}
try {
if (typeof loraHelper.rebuildUI === 'function') {
loraHelper.rebuildUI();
}
} catch (e) { /* ignore */ }
}
if (doParams) {
const defaults = mergedGenerationDefaults();
const capture = state.lastUserParamIntent;
const aspectSize = sizeFromAspect(patch.aspect);
if (patch.aspect != null && !shouldSkipSessionRollback('aspect', patch.aspect)) {
if (aspectSize) {
setVal('input_width', String(aspectSize[0]));
setVal('input_height', String(aspectSize[1]));
}
if (capture) {
rememberSessionExact({ aspect: patch.aspect });
}
} else if (patch.aspect == null && isEmptyParamField(val('input_width'), { treatZeroEmpty: true })
&& isEmptyParamField(val('input_height'), { treatZeroEmpty: true }) && defaults.aspect) {
const fill = sizeFromAspect(defaults.aspect);
if (fill) {
setVal('input_width', String(fill[0]));
setVal('input_height', String(fill[1]));
}
} else {
if (patch.width != null && !shouldSkipSessionRollback('width', patch.width)) {
setVal('input_width', String(patch.width));
if (capture) {
rememberSessionExact({ width: patch.width });
}
} else if (patch.width == null && isEmptyParamField(val('input_width'), { treatZeroEmpty: true }) && defaults.width != null) {
setVal('input_width', String(defaults.width));
}
if (patch.height != null && !shouldSkipSessionRollback('height', patch.height)) {
setVal('input_height', String(patch.height));
if (capture) {
rememberSessionExact({ height: patch.height });
}
} else if (patch.height == null && isEmptyParamField(val('input_height'), { treatZeroEmpty: true }) && defaults.height != null) {
setVal('input_height', String(defaults.height));
}
}
if (patch.steps != null && !shouldSkipSessionRollback('steps', patch.steps)) {
setVal('input_steps', String(patch.steps));
if (capture) {
rememberSessionExact({ steps: patch.steps });
}
} else if (patch.steps == null && isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) {
setVal('input_steps', String(defaults.steps));
}
if (patch.cfg != null && !shouldSkipSessionRollback('cfg', patch.cfg)) {
if (document.getElementById('input_cfgscale')) {
setVal('input_cfgscale', String(patch.cfg));
} else {
setVal('input_cfg', String(patch.cfg));
}
if (capture) {
rememberSessionExact({ cfg: patch.cfg });
}
} else if (patch.cfg == null) {
const cfgRaw = val('input_cfgscale') || val('input_cfg');
if (isEmptyParamField(cfgRaw, { treatZeroEmpty: true }) && defaults.cfg != null) {
if (document.getElementById('input_cfgscale')) {
setVal('input_cfgscale', String(defaults.cfg));
} else if (document.getElementById('input_cfg')) {
setVal('input_cfg', String(defaults.cfg));
}
}
}
if (patch.vary === true) {
setVal('input_seed', '-1');
} else if (patch.lock_seed === true) {
const cur = val('input_seed');
if (cur && String(cur) !== '-1') {
setVal('input_seed', cur);
}
} else if (patch.seed != null && !shouldSkipSessionRollback('seed', patch.seed)) {
setVal('input_seed', String(patch.seed));
if (capture) {
rememberSessionExact({ seed: patch.seed });
}
}
if (patch.sigma_shift != null && !shouldSkipSessionRollback('sigma_shift', patch.sigma_shift)) {
setVal('input_sigmashift', String(patch.sigma_shift));
if (capture) {
rememberSessionExact({ sigma_shift: patch.sigma_shift });
}
} else if (patch.sigma_shift == null && isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
setVal('input_sigmashift', String(defaults.sigma_shift));
}
if (patch.sampler != null) {
if (document.getElementById('input_sampler')) {
setVal('input_sampler', String(patch.sampler));
}
if (capture) {
rememberSessionExact({ sampler: patch.sampler });
}
}
if (patch.scheduler != null && document.getElementById('input_scheduler')) {
setVal('input_scheduler', String(patch.scheduler));
if (capture) {
rememberSessionExact({ scheduler: patch.scheduler });
}
}
const batch = patch.images != null ? patch.images : patch.batch;
if (batch != null && !shouldSkipSessionRollback('images', batch)) {
if (document.getElementById('input_images')) {
setVal('input_images', String(batch));
} else if (document.getElementById('input_batchsize')) {
setVal('input_batchsize', String(batch));
}
if (capture) {
rememberSessionExact({ images: batch });
}
} else if (batch == null) {
const batchId = document.getElementById('input_images') ? 'input_images' : (document.getElementById('input_batchsize') ? 'input_batchsize' : null);
const defBatch = defaults.images != null ? defaults.images : defaults.batch;
if (batchId && isEmptyParamField(val(batchId), { treatZeroEmpty: true }) && defBatch != null) {
setVal(batchId, String(defBatch));
}
}
}
if (doInit) {
const creativity = patch.init_creativity != null ? patch.init_creativity : patch.denoise;
if (creativity != null && document.getElementById('input_initimagecreativity')) {
setVal('input_initimagecreativity', String(creativity));
openInitImageGroup();
}
if (patch.mask_blur != null && document.getElementById('input_maskblur')) {
setVal('input_maskblur', String(patch.mask_blur));
}
if (patch.mask_grow != null) {
if (document.getElementById('input_maskgrow')) {
setVal('input_maskgrow', String(patch.mask_grow));
} else if (document.getElementById('input_maskshrinkgrow')) {
setVal('input_maskshrinkgrow', String(patch.mask_grow));
}
}
if (patch.clear_init_image || patch.clear_mask_image) {
if (patch.clear_init_image) {
clearFileParam('input_initimage');
}
if (patch.clear_mask_image) {
clearFileParam('input_maskimage');
}
if (patch.clear_init_image && patch.clear_mask_image) {
const toggler = document.getElementById('input_group_content_initimage_toggle');
if (toggler) {
toggler.checked = false;
triggerChangeForEl(toggler);
}
}
}
if (patch.select_slot) {
const id = normalizeSlotId(patch.select_slot);
if (slotById(id)) {
state.selectedSlotId = id;
renderBoard();
}
}
if (patch.snapshot_generate) {
snapshotGenerateToRef();
}
const initId = patch.slot_to_init || (patch.use_init_image || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_init')) ? state.selectedSlotId : null);
const maskId = patch.slot_to_mask || null;
const src = resolveSlotSrc(patch.slot_to_init) || selectedSrc() || findCurrentGenerateSrc();
const wantInit = patch.use_init_image === true
|| !!patch.slot_to_init
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_init'));
const wantMask = patch.use_mask_image === true
|| !!patch.slot_to_mask
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_mask'));
if (wantInit) {
const initSrc = resolveSlotSrc(initId) || src;
if (initSrc) {
await setInitFromSrc(initSrc);
} else {
setStatus('No image for Init — drop a ref or wait for Generate');
}
}
if (wantMask) {
const maskSrc = resolveSlotSrc(maskId) || src;
if (maskSrc) {
await setMaskFromSrc(maskSrc);
} else {
setStatus('No image for Mask — drop a mask (white=edit) first');
}
}
if (patch.slot_to_prompt_image) {
setStatus('Prompt Images: drop the ref into the Swarm prompt box (no auto helper yet)');
}
}
// 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 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);
}
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
const wantSwitch = acts.includes('persona_switch')
|| (patch.persona && typeof patch.persona === 'string')
|| patch._persona_cloned
|| patch._persona_written;
if (wantSwitch) {
const newId = String(patch.persona || patch._persona_cloned || patch._persona_written || '').trim();
if (newId && AssistentConfigSafeIdClient(newId)) {
await refreshPersonasAndSwitch(newId);
} else if (acts.includes('persona_clone') || acts.includes('persona_write') || patch.persona_clone) {
await refreshPersonasAndSwitch(null);
}
}
syncChipHighlight();
syncLiveParamsBar();
syncBuildGenButton();
if (state.activeChatId && !state.restoringChat) {
const chat = findChat(state.activeChatId);
if (chat) {
chat.params = snapshotChatParams();
chat.updatedAt = Date.now();
persistChatsStore();
}
}
if (!state.restoringChat) {
setStatus(patch._persona_error ? `Persona: ${patch._persona_error}` : 'Applied patch');
}
}
function AssistentConfigSafeIdClient(id) {
return /^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/.test(String(id || ''));
}
async function refreshPersonasAndSwitch(preferId) {
await new Promise((resolve) => {
genericRequest(
'AssistentListPersonas',
{},
async (data) => {
if (Array.isArray(data?.personas)) {
state.personas = data.personas.map((p) => ({
id: p.id,
title: p.title,
accent: p.accent,
source: p.source,
}));
renderPersonaOptions(state.personas, preferId || $('sa_persona')?.value);
}
if (preferId && $('sa_persona')) {
if ([...$('sa_persona').options].some((o) => o.value === preferId)) {
$('sa_persona').value = preferId;
await applyPersonaForChat(preferId, { quiet: true });
}
} else {
loadConfig($('sa_persona')?.value, () => resolve());
return;
}
resolve();
},
0,
() => resolve(),
);
});
}
function triggerGenerate() {
try {
if (typeof mainGenHandler !== 'undefined' && mainGenHandler && typeof mainGenHandler.doGenerate === 'function') {
mainGenHandler.doGenerate();
return true;
}
} catch (e) {
console.warn('Assistent: doGenerate failed', e);
}
const btn =
document.getElementById('generate_button') ||
document.getElementById('alt_generate_button') ||
document.querySelector('button.generate-button') ||
document.querySelector('#generate_button, button[id*="generate"]');
if (btn) {
btn.click();
return true;
}
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 (!shouldParkLlmBeforeGen() || !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;
if (ok) {
state.llmParked = true;
state.expectColdLoad = true;
}
resolve(!!ok);
};
setTimeout(() => finish(false), 8000);
genericRequest('AssistentParkLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
});
}
/** 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 || typeof genericRequest !== 'function') {
resolve(false);
return;
}
if (!force && !state.llmParked) {
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 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() {
if (state.waitImageTimer) {
clearInterval(state.waitImageTimer);
state.waitImageTimer = null;
}
}
function bumpChatEpoch() {
state.chatEpoch = (state.chatEpoch || 0) + 1;
return state.chatEpoch;
}
function clearInFlightUi({ status } = {}) {
state.busy = false;
state.generating = false;
state.pendingSilentGen = false;
if (state.streamEl) {
try { state.streamEl.remove(); } catch (e) { /* ignore */ }
state.streamEl = null;
state.streamMeta = null;
}
setInterruptVisible(false);
syncGenerateBusy();
syncPatchActionAvailability();
if (status != null) {
stopBusyUi(status);
} else {
stopBusyUi('');
}
}
/** Invalidate in-flight Assistent WS/wait; optionally also interrupt Swarm Generate. */
function abortInFlightWork({ status, interruptSwarm = false } = {}) {
bumpChatEpoch();
cancelWaitForNewImage();
if (interruptSwarm) {
try {
if (typeof doInterrupt === 'function') {
doInterrupt(false);
} else if (typeof genericRequest === 'function') {
genericRequest('InterruptAll', { other_sessions: false }, () => {}, 0, () => {});
}
} catch (e) { /* ignore */ }
}
clearInFlightUi({ status: status != null ? status : '' });
}
function doInterruptNow() {
bumpChatEpoch();
cancelWaitForNewImage();
try {
if (typeof doInterrupt === 'function') {
doInterrupt(false);
return;
}
} catch (e) { /* ignore */ }
if (typeof genericRequest === 'function') {
genericRequest('InterruptAll', { other_sessions: false }, () => {}, 0, () => {});
}
}
function waitForNewImage(prevSrc, timeoutMs = 180000) {
cancelWaitForNewImage();
const epoch = state.chatEpoch;
const prev = String(prevSrc || '');
return new Promise((resolve) => {
const start = Date.now();
let sawRunning = false;
let idleTicks = 0;
let candidate = null;
state.waitImageTimer = setInterval(() => {
if (epoch !== state.chatEpoch) {
cancelWaitForNewImage();
resolve(null);
return;
}
const running = isSwarmGenerateRunning();
if (running) {
sawRunning = true;
idleTicks = 0;
} else if (sawRunning) {
idleTicks += 1;
}
const raw = findCurrentGenerateSrc();
const src = raw && !looksLikeModelPreview(raw) ? raw : null;
if (src && src !== prev) {
candidate = src;
}
// Primary: Swarm finished after we saw it run — accept current/changed frame
// even when ViewImage URL was reused (same string, new bytes).
if (sawRunning && !running && idleTicks >= 2) {
cancelWaitForNewImage();
resolve(candidate || src || null);
return;
}
// Missed the running flag (very fast Turbo): URL changed and Swarm is idle.
if (candidate && !running && Date.now() - start > 500) {
cancelWaitForNewImage();
resolve(candidate);
return;
}
if (Date.now() - start > timeoutMs) {
cancelWaitForNewImage();
resolve(candidate || src || null);
}
}, 400);
});
}
const VARIANT_STRIP_KEYS = [
'variants', 'label', 'notes', 'actions', 'look_at', 'vision_from', 'vision_slots',
'search_query', 'civitai_query', 'memories', 'memory', 'memory_query', 'memory_kind',
'tag_query', 'user_prefs', 'controls', 'skills', 'persona_shelves', 'inventory_query',
'pack',
];
/** 24 partial patches from the model; fewer than 2 → treat as a normal single Generate. */
function normalizeVariantList(patch) {
if (!patch || !Array.isArray(patch.variants)) {
return null;
}
const items = patch.variants.filter((v) => v && typeof v === 'object' && !Array.isArray(v));
if (items.length < 2) {
return null;
}
return items.slice(0, MAX_GEN_VARIANTS);
}
function stripMetaPatchKeys(obj) {
const out = { ...(obj || {}) };
for (const k of VARIANT_STRIP_KEYS) {
delete out[k];
}
return out;
}
function mergeVariantPatch(base, item, index) {
const merged = { ...stripMetaPatchKeys(base), ...stripMetaPatchKeys(item) };
merged.images = 1;
delete merged.batch;
if (merged.seed == null && merged.lock_seed !== true) {
merged.seed = -1;
merged.vary = true;
}
merged.actions = ['generate'];
const labelRaw = item?.label != null ? String(item.label).trim() : '';
return {
id: `var${index + 1}`,
label: (labelRaw || `Вариант ${index + 1}`).slice(0, 48),
patch: merged,
};
}
function finishedGenResultCount() {
return (state.genResults || []).filter((r) => r && r.src).length;
}
function isMultiGenResults() {
return finishedGenResultCount() > 1 || (state.genResults || []).length > 1;
}
function clearGenResults() {
state.genResults = [];
state.selectedGenResultId = null;
if (state.lightboxIndex >= 0) {
closeGenLightbox();
}
}
function selectGenResult(id, { restore = true, openViewer = false } = {}) {
const row = (state.genResults || []).find((r) => r.id === id);
if (!row) {
return false;
}
state.selectedGenResultId = row.id;
const gen = generateSlot();
if (gen && row.src) {
gen.src = row.src;
}
if (restore && row.patch) {
applyPatch(row.patch, 'all').catch(() => {});
syncLiveParamsBar();
}
renderBoard();
if (openViewer && row.src) {
openGenLightbox(row.id);
}
return true;
}
async function runGenerateFromPatch(patch, opts = {}) {
const force = !!opts.force;
if ((!force && !$('sa_auto_generate')?.checked) || !patchHasGenTrigger(patch)) {
return null;
}
const variantItems = normalizeVariantList(patch);
const jobs = variantItems
? variantItems.map((item, i) => mergeVariantPatch(patch, item, i))
: null;
if (jobs) {
state.genResults = jobs.map((j) => ({
id: j.id,
label: j.label,
src: null,
patch: j.patch,
}));
state.selectedGenResultId = null;
setBoardTab('generate', { persist: true });
renderBoard();
} else {
clearGenResults();
}
const epoch = state.chatEpoch;
if (shouldParkLlmBeforeGen()) {
startBusyUi('parking');
setStatus('Освобождаю VRAM…');
await parkLlm();
if (epoch !== state.chatEpoch) {
return null;
}
}
state.generating = true;
setInterruptVisible(true);
startBusyUi('generating');
let lastSrc = null;
const steps = jobs || [{
id: 'var1',
label: 'Generate',
patch: { ...stripMetaPatchKeys(patch), actions: ['generate'] },
}];
for (let i = 0; i < steps.length; i++) {
if (epoch !== state.chatEpoch) {
break;
}
const job = steps[i];
if (jobs) {
setStatus(`Вариант ${i + 1}/${steps.length}: ${job.label}`);
} else {
setStatus('Генерация…');
}
startBusyUi('generating');
// Multi: each step applies its merged patch. Single: caller already applied.
if (jobs) {
await applyPatch(job.patch, 'all');
syncLiveParamsBar();
}
if (epoch !== state.chatEpoch) {
break;
}
const prev = findCurrentGenerateSrc();
const ok = triggerGenerate();
if (!ok) {
setStatus(jobs
? `Не удалось запустить вариант ${i + 1}/${steps.length}`
: 'Не удалось запустить Generate');
if (!jobs) {
break;
}
continue;
}
const src = await waitForNewImage(prev);
if (epoch !== state.chatEpoch) {
break;
}
if (src) {
lastSrc = src;
if (jobs) {
const row = state.genResults.find((r) => r.id === job.id);
if (row) {
row.src = src;
}
state.selectedGenResultId = job.id;
}
const gen = generateSlot();
if (gen) {
gen.src = src;
}
renderBoard();
}
}
state.generating = false;
setInterruptVisible(state.busy);
state.expectColdLoad = true;
if (jobs && lastSrc && state.selectedGenResultId && epoch === state.chatEpoch) {
const row = state.genResults.find((r) => r.id === state.selectedGenResultId);
if (row?.patch) {
await applyPatch(row.patch, 'all');
syncLiveParamsBar();
}
}
const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
const multiDone = !!(jobs && finishedGenResultCount() > 1);
const willAutoCritique = !multiDone && !!$('sa_auto_critique')?.checked;
if (state.view === 'chat' && paneVisible && !willAutoCritique && epoch === state.chatEpoch) {
startBusyUi('warming');
setStatus('Возвращаю LLM в GPU…');
await warmLlm({ force: true });
}
if (epoch !== state.chatEpoch) {
return null;
}
if (jobs) {
const n = finishedGenResultCount();
const msg = n > 0
? (n > 1 ? `Готово · ${n} вариантов` : `Готово · 1 вариант`)
: 'Generate завершён (новое изображение не найдено)';
if (!state.busy) {
stopBusyUi(msg);
}
setStatus(msg);
// Multi-grid: skip auto-critique / auto look_at (caller checks isMultiGenResults).
return multiDone ? null : lastSrc;
}
if (!state.busy) {
stopBusyUi(lastSrc ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
}
if (lastSrc) {
const gen = generateSlot();
if (gen) {
gen.src = lastSrc;
renderBoard();
}
setStatus('Generate готов');
return lastSrc;
}
if (state.busy) {
setStatus('Generate завершён (новое изображение не найдено)');
}
return null;
}
/** Resolves the freshest real Generate frame — never a model preview. */
async function resolveFinishedGenerateSrc(hint, { settleMs = 20000 } = {}) {
scrubPreviewFromGenerateSlot();
let src = hint && !looksLikeModelPreview(hint) ? hint : null;
if (!src) {
src = findCurrentGenerateSrc();
}
// Batch still running: the last frame is not the final one yet.
if (isGenerateUnavailable()) {
const settled = await waitForNewImage(src, settleMs);
if (settled) {
src = settled;
}
}
return src && !looksLikeModelPreview(src) ? src : null;
}
async function maybeAutoCritique(imageSrc) {
if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed || isMultiGenResults()) {
return;
}
const src = await resolveFinishedGenerateSrc(imageSrc);
if (!src) {
setStatus('Авто-критика пропущена — нет готового кадра Generate');
return;
}
state.critiqueHopUsed = true;
setPackValue('critique_image', { flash: true });
if ($('sa_input')) {
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
}
const gen = generateSlot();
if (gen) {
gen.attach = true;
gen.src = src;
renderBoard();
}
setStatus('Auto-critique…');
await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
restoreDefaultPackAfterHop();
}
/** After Generate: send look_at with JPEG when sa_auto_vision is on (skipped if auto-critique already attaches vision). */
async function maybeAutoVisionLook(imageSrc) {
if (!wantsAutoVision() || $('sa_auto_critique')?.checked || state.visionHopUsed || state.busy || isMultiGenResults()) {
return;
}
const src = await resolveFinishedGenerateSrc(imageSrc);
if (!src) {
return;
}
const gen = generateSlot();
if (gen) {
gen.src = src;
gen.attach = true;
renderBoard();
}
state.visionHopUsed = true;
setPackValue('critique_image', { flash: true });
if ($('sa_input')) {
$('sa_input').value = 'Look at the Generate result and briefly say what worked and what to fix next.';
}
setStatus('Auto look_at…');
await sendChat({ fromVisionHop: true, forceSlotIds: [GEN_ID], skipAutoPack: true });
restoreDefaultPackAfterHop();
}
/** Board action: attach the finished Generate frame and ask for a verdict. */
async function askLookAtResult() {
if (state.busy || state.generating) {
setStatus('Занято — дождись конца ответа или Стоп');
return;
}
if (!updateGate()) {
setStatus('Выбери модель Krea 2');
return;
}
const preferred = (state.genResults || []).find((r) => r.id === state.selectedGenResultId && r.src)?.src
|| generateSlot()?.src;
const src = await resolveFinishedGenerateSrc(preferred, { settleMs: 8000 });
if (!src) {
setStatus('Нет готового кадра Generate — сначала сгенерируй');
return;
}
const gen = generateSlot();
if (gen) {
gen.src = src;
gen.attach = true;
}
setBoardTab('generate');
renderBoard();
setView('chat');
const label = (state.genResults || []).find((r) => r.id === state.selectedGenResultId)?.label;
setPackValue('critique_image', { flash: true });
if ($('sa_input')) {
$('sa_input').value = label
? `Посмотри результат «${label}»: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.`
: 'Посмотри результат: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.';
}
await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true });
}
function currentPersonaInfo() {
const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
const known = (state.personas || []).find((p) => p && p.id === id);
return {
id,
title: (known && known.title) || ({
neutral: 'Нейтральный',
lewd: 'Пошляк',
aggressive: 'Агрессивный',
}[id] || id),
};
}
function mountAssistantMeta(div, meta = {}) {
if (!div || div.querySelector('.sa-msg-meta')) {
return;
}
const persona = meta.persona || currentPersonaInfo();
const pack = meta.pack || $('sa_pack')?.value || '';
div.dataset.persona = persona.id || 'neutral';
if (pack) {
div.dataset.pack = pack;
}
const row = document.createElement('div');
row.className = 'sa-msg-meta';
const chip = document.createElement('span');
chip.className = `sa-persona-mark sa-persona-${persona.id || 'neutral'}`;
chip.textContent = persona.title || persona.id;
chip.title = `Характер: ${persona.title || persona.id}${pack ? ` · режим ${pack}` : ''}`;
row.appendChild(chip);
if (pack && pack !== 'ordinary' && pack !== 'write_prompt') {
const packEl = document.createElement('span');
packEl.className = 'sa-pack-mark';
packEl.textContent = pack.replace(/_/g, ' ');
packEl.title = `Режим: ${pack}`;
row.appendChild(packEl);
}
div.insertBefore(row, div.firstChild);
}
function appendMessage(role, text, patch, civitaiResults, meta) {
const box = $('sa_messages');
if (!box) {
return null;
}
hideChatEmpty();
const div = document.createElement('div');
div.className = `sa-msg ${role}`;
if (role === 'assistant') {
mountAssistantMeta(div, meta);
}
const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null };
const finalPatch = patch || extracted;
if (role === 'assistant') {
setAssistantBody(div, prose || text || '');
} else {
div.textContent = prose || text || '';
}
// Historical replay: show prose only — no Apply strip (lastPatch comes from chat.params).
if (finalPatch && !(meta && meta.historical)) {
const silent = !!(meta && meta.silentPatch);
mountPatchBlock(div, finalPatch, { silent });
}
if (civitaiResults && civitaiResults.length) {
div.appendChild(buildCivitaiCards(civitaiResults));
}
box.appendChild(div);
scrollMessagesToBottom({ force: true });
return div;
}
function beginStreamMessage(meta) {
const box = $('sa_messages');
if (!box) {
return null;
}
hideChatEmpty();
const div = document.createElement('div');
div.className = 'sa-msg assistant sa-streaming sa-typing';
mountAssistantMeta(div, meta);
const body = document.createElement('div');
body.className = 'sa-msg-body';
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);
box.appendChild(div);
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 re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
while ((match = re.exec(t)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
? SA.isTerminalStreamPatch(obj)
: (isPatchObject(obj) || isCardObject(obj));
if (terminal) {
return true;
}
} catch (e) { /* ignore */ }
}
return false;
}
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());
const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
? SA.isTerminalStreamPatch(obj)
: (isPatchObject(obj) || isCardObject(obj));
if (terminal) {
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);
}
if (state.streamEl) {
if (state.streamEl.classList.contains('sa-typing')) {
state.streamEl.classList.remove('sa-typing');
state.streamText = '';
setAssistantBody(state.streamEl, '', { live: true });
}
state.gotDelta = true;
state.expectColdLoad = false;
if (state.busyPhase !== 'refining') {
setBusyPhase('streaming');
}
state.streamText = (state.streamText || '') + (delta || '');
if (streamHasClosedPatchFence(state.streamText)) {
state.streamText = trimToClosedPatchFence(state.streamText);
state.streamFenceDone = true;
}
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
}
}
function finalizeStreamMessage(fullReply, civitaiResults) {
const el = state.streamEl;
const meta = state.streamMeta;
state.streamEl = null;
state.streamMeta = null;
state.streamText = '';
state.streamFenceDone = false;
if (!el) {
appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined);
return;
}
el.classList.remove('sa-streaming', 'sa-typing');
mountAssistantMeta(el, meta || undefined);
const card = extractCardJson(fullReply);
const { prose, patch } = extractPatch(fullReply);
setAssistantBody(el, prose || fullReply || '');
el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove());
if (patch && !isCardObject(patch) && !(card && !patch.prompt && !patch.actions && !patch.loras)) {
const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
mountPatchBlock(el, patch, { silent });
} else if (card) {
const wrap = document.createElement('div');
wrap.className = 'sa-patch sa-card-json-preview';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(card, null, 2);
wrap.appendChild(pre);
el.appendChild(wrap);
}
if (civitaiResults && civitaiResults.length) {
el.appendChild(buildCivitaiCards(civitaiResults));
}
scrollMessagesToBottom();
}
function buildCivitaiCards(results) {
const list = document.createElement('div');
list.className = 'sa-civitai-list';
for (const r of results) {
const card = document.createElement('div');
card.className = 'sa-civitai-card' + (r.already_installed ? ' sa-installed' : '');
const title = document.createElement('div');
title.className = 'sa-civitai-title';
title.textContent = r.name || r.file_name || 'LoRA';
card.appendChild(title);
const meta = document.createElement('div');
meta.className = 'sa-civitai-meta';
const bits = [
r.base_model || '?',
r.krea_likely ? 'Krea?' : null,
r.already_installed ? 'already installed' : null,
(r.triggers || []).slice(0, 3).join(', ') || null,
].filter(Boolean);
meta.textContent = bits.join(' · ');
card.appendChild(meta);
const actions = document.createElement('div');
actions.className = 'sa-civitai-actions';
if (r.already_installed) {
const note = document.createElement('span');
note.textContent = 'Уже установлена';
actions.appendChild(note);
} else if (r.download_url) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'basic-button sa-primary';
btn.textContent = 'Подтвердить скачивание';
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
actions.appendChild(btn);
} else {
const note = document.createElement('span');
note.textContent = 'Нет URL скачивания';
actions.appendChild(note);
}
card.appendChild(actions);
list.appendChild(card);
}
return list;
}
function downloadCivitaiLoRA(card, btn) {
if (!card.download_url) {
return;
}
if (btn) {
btn.disabled = true;
btn.textContent = 'Скачиваю…';
}
setStatus(`Скачиваю ${card.file_name || card.name}…`);
setInterruptVisible(true);
const payload = {
url: card.download_url,
type: 'LoRA',
name: card.file_name || card.name || 'lora',
};
const onDone = (ok, msg) => {
setInterruptVisible(state.busy || state.generating);
if (ok) {
setStatus(`Скачано ${payload.name}`);
if (btn) {
btn.textContent = 'Скачано';
}
refreshInventory(async () => {
await maybeWriteCardAfterDownload({
kind: 'lora',
name: payload.name,
civitai: card,
});
}, { rescan: true });
} else {
setStatus(msg || 'Ошибка скачивания');
if (btn) {
btn.disabled = false;
btn.textContent = 'Подтвердить скачивание';
}
appendMessage('error', msg || 'Ошибка скачивания');
}
};
if (typeof makeWSRequest === 'function') {
makeWSRequest(
'DoModelDownloadWS',
payload,
(data) => {
if (data.error) {
onDone(false, String(data.error));
return;
}
if (data.success || data.overall_percent >= 1 || data.current_percent >= 1) {
if (data.success || data.overall_percent >= 0.99) {
// Swarm docs: download does not always refresh model list — force both.
triggerSwarmModelRefresh(() => onDone(true));
} else if (data.current_percent != null) {
setStatus(`Download ${(data.current_percent * 100).toFixed(0)}%`);
}
}
},
0,
(err) => onDone(false, String(err || 'Download failed')),
);
} else {
onDone(false, 'makeWSRequest unavailable');
}
}
async function maybeWriteCardAfterDownload({ kind, name, civitai }) {
const display = name || civitai?.file_name || civitai?.name || 'model';
appendSystemNote(`Downloaded ${display}. Writing a recommendation card…`);
setPackValue('catalog_card', { flash: true });
const meta = {
triggers: civitai?.triggers || [],
base_model: civitai?.base_model,
civitai_url: civitai?.url || civitai?.civitai_url,
version_id: civitai?.version_id || civitai?.modelVersionId,
name: display,
};
if ($('sa_input')) {
$('sa_input').value = '';
}
await sendChat({
forcedUserText: `LoRA "${display}" is now installed. Write a recommendation card (JSON) using its triggers/metadata. Then briefly suggest how to enable it in the next generate.`,
skipSlash: true,
skipAutoPack: true,
fromDownload: true,
fromCards: true,
cardTarget: { kind: kind || 'lora', name: display, meta },
});
}
function wantsAutoVision() {
return !!$('sa_auto_vision')?.checked;
}
function looksLikeModelPreview(src) {
const s = String(src || '').toLowerCase();
if (!s) {
return false;
}
// Only SwarmUI checkpoint/LoRA card routes — not bare "/models/" (matches Civitai page URLs).
return s.includes('.preview.')
|| s.includes('placeholder')
|| s.includes('/viewspecial/')
|| s.includes('viewspecial/')
|| s.includes('/view/models/')
|| /\/view\/models\//.test(s)
|| /[?&](?:path|file)=[^&]*\.preview\./i.test(s);
}
function findCurrentGenerateSrc({ allowPreview = false } = {}) {
let src = null;
try {
const cur = document.getElementById('current_image_img')
|| document.querySelector('#current_image img')
|| document.querySelector('.current-image img')
|| document.querySelector('#current_image_batch img');
if (cur) {
src = cur.dataset?.src || cur.getAttribute?.('data-src') || cur.src || null;
}
} catch (e) { /* ignore */ }
if (!src) {
try {
if (typeof currentMetadataMap !== 'undefined' && currentMetadataMap && currentMetadataMap.image) {
src = currentMetadataMap.image;
}
} catch (e) { /* ignore */ }
}
if (!src) {
return null;
}
// Strip cache-busters for classification, keep original for display when accepted.
if (!allowPreview && looksLikeModelPreview(src)) {
return null;
}
return src;
}
function scrubPreviewFromGenerateSlot() {
const slot = generateSlot();
if (!slot?.src) {
return false;
}
if (!looksLikeModelPreview(slot.src)) {
return false;
}
slot.src = null;
slot.attach = false;
syncLastImageAlias();
return true;
}
function refreshImagePreview() {
scrubPreviewFromGenerateSlot();
if (wantsAutoVision()) {
const gen = generateSlot();
if (gen) {
const src = findCurrentGenerateSrc();
if (src) {
gen.attach = true;
gen.src = src;
} else {
gen.attach = false;
}
renderBoard();
}
}
syncGenerateSlot();
}
function fileToDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function acceptImageFile(file, slotId) {
if (!file || !String(file.type || '').startsWith('image/')) {
setStatus('Not an image file');
return false;
}
const dataUrl = await fileToDataUrl(file);
if (slotId) {
return setSlotSrc(slotId, dataUrl, { note: `Loaded ${file.name || 'image'}` });
}
return putImageOnBoard(dataUrl, { note: `Loaded ${file.name || 'image'}` });
}
async function handleDropDataTransfer(dt, slotId) {
if (!dt) {
return false;
}
if (dt.files && dt.files.length) {
for (const file of dt.files) {
if (String(file.type || '').startsWith('image/')) {
return acceptImageFile(file, slotId);
}
}
}
const uri = (dt.getData('text/uri-list') || dt.getData('text/plain') || '').trim();
if (uri) {
const first = uri.split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'));
if (first) {
if (slotId) {
return setSlotSrc(slotId, first, { note: 'Image from drag' });
}
return putImageOnBoard(first, { note: 'Image from drag' });
}
}
const html = dt.getData('text/html') || '';
const m = html.match(/src=["']([^"']+)["']/i);
if (m && m[1]) {
if (slotId) {
return setSlotSrc(slotId, m[1], { note: 'Image from drag' });
}
return putImageOnBoard(m[1], { note: 'Image from drag' });
}
return false;
}
async function imageToBase64ForOllama(src, maxEdge = 1024) {
if (!src) {
return null;
}
const dataUrl = await srcToDataUrl(src);
if (!dataUrl) {
return null;
}
try {
const img = await new Promise((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = reject;
el.src = dataUrl;
});
const w = img.naturalWidth || img.width || 0;
const h = img.naturalHeight || img.height || 0;
const edge = Math.max(w, h);
const canvas = document.createElement('canvas');
if (!edge || edge <= maxEdge) {
canvas.width = Math.max(w, 1);
canvas.height = Math.max(h, 1);
canvas.getContext('2d').drawImage(img, 0, 0);
} else {
const scale = maxEdge / edge;
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
}
const jpeg = canvas.toDataURL('image/jpeg', 0.85);
const i = jpeg.indexOf(',');
return i >= 0 ? jpeg.slice(i + 1) : null;
} catch (e) {
console.warn('Assistent: vision resize failed', e);
const i = dataUrl.indexOf(',');
return i >= 0 ? dataUrl.slice(i + 1) : null;
}
}
async function srcToDataUrl(src) {
if (src.startsWith('data:')) {
return src;
}
try {
const resp = await fetch(src);
const blob = await resp.blob();
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} catch (e) {
console.warn('Assistent: vision fetch failed', e);
return null;
}
}
function loadSettings() {
// One-shot: old default was write_prompt → migrate to ordinary комбайн.
if (!localStorage.getItem(LS_PACK_ORDINARY_MIG)) {
if (localStorage.getItem(LS_PACK) === 'write_prompt') {
localStorage.setItem(LS_PACK, 'ordinary');
}
localStorage.setItem(LS_PACK_ORDINARY_MIG, '1');
}
const base = localStorage.getItem(LS_BASE);
const model = localStorage.getItem(LS_MODEL);
const pack = localStorage.getItem(LS_PACK);
const persona = localStorage.getItem(LS_PERSONA);
const view = localStorage.getItem(LS_VIEW);
const auto = localStorage.getItem(LS_AUTO_VISION);
const autoApply = localStorage.getItem(LS_AUTO_APPLY);
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;
}
if (pack && $('sa_pack')) {
$('sa_pack').value = pack;
}
if (persona && $('sa_persona')) {
$('sa_persona').value = persona;
}
if (auto != null && $('sa_auto_vision')) {
$('sa_auto_vision').checked = auto === '1';
}
if ($('sa_auto_apply')) {
$('sa_auto_apply').checked = autoApply == null ? true : autoApply === '1';
}
if ($('sa_auto_generate')) {
$('sa_auto_generate').checked = autoGen == null ? true : autoGen === '1';
}
if ($('sa_auto_critique') && autoCrit != null) {
$('sa_auto_critique').checked = autoCrit === '1';
}
if ($('sa_auto_download') && autoDl != null) {
$('sa_auto_download').checked = autoDl === '1';
}
// Default OFF — parking a VL 7B before every Generate caused 12 min reloads.
if ($('sa_park_llm')) {
$('sa_park_llm').checked = parkLlm === '1';
}
if (model) {
state.preferredModel = model;
}
const embed = localStorage.getItem(LS_EMBED);
if (embed) {
state.preferredEmbed = embed;
}
if (paneW) {
document.documentElement.style.setProperty('--sa-image-width', paneW);
}
if (view === 'cards' || view === 'chat' || view === 'settings') {
state.view = view;
}
const boardTab = localStorage.getItem(LS_BOARD_TAB);
if (boardTab === 'refs' || boardTab === 'generate') {
state.boardTab = boardTab;
}
}
function collectUiState() {
return {
pack: $('sa_pack')?.value || defaultPackId(),
persona: $('sa_persona')?.value || 'neutral',
auto_vision: !!$('sa_auto_vision')?.checked,
auto_apply: !!$('sa_auto_apply')?.checked,
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 || '',
model: $('sa_model')?.value || '',
view: state.view || 'chat',
board_tab: state.boardTab || 'generate',
};
}
/**
* Fills fields the browser has never seen from sqlite ui_state, so a fresh
* browser on the same volume inherits the previous session. Existing localStorage wins.
* auto_download is only ever restored when it is off — the danger flag stays opt-in.
*/
async function applyDiskUiState() {
const persist = diskPersist();
if (!persist) {
return;
}
let ui = null;
try {
ui = await persist.loadUiState();
} catch (e) {
return;
}
if (!ui || typeof ui !== 'object') {
return;
}
const fill = (lsKey, value, apply) => {
if (value == null || value === '' || localStorage.getItem(lsKey) != null) {
return;
}
localStorage.setItem(lsKey, String(value));
apply?.(String(value));
};
fill(LS_BASE, ui.base_url, (v) => { if ($('sa_base_url')) { $('sa_base_url').value = v; } });
fill(LS_MODEL, ui.model, (v) => { state.preferredModel = v; });
fill(LS_EMBED, ui.embed_model, (v) => { state.preferredEmbed = 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_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
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') {
fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
}
for (const [key, lsKey, id] of [
['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
['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;
}
const on = ui[key] === true || ui[key] === '1' || ui[key] === 1;
if (on && key === 'auto_download') {
continue;
}
localStorage.setItem(lsKey, on ? '1' : '0');
const el = $(id);
if (el) {
el.checked = on;
}
}
}
function saveUiStateToDisk() {
diskPersist()?.saveUiState(collectUiState());
}
function saveSettings() {
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
localStorage.setItem(LS_EMBED, $('sa_embed_model')?.value || state.preferredEmbed || '');
localStorage.setItem(LS_PACK, $('sa_pack')?.value || defaultPackId());
localStorage.setItem(LS_PERSONA, $('sa_persona')?.value || 'neutral');
localStorage.setItem(LS_VIEW, state.view || 'chat');
localStorage.setItem(LS_AUTO_VISION, $('sa_auto_vision')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_APPLY, $('sa_auto_apply')?.checked ? '1' : '0');
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();
}
function persistServerSettings() {
if (typeof genericRequest !== 'function') {
return;
}
const skills = {};
document.querySelectorAll('#sa_skills_box input[data-skill]')?.forEach((el) => {
skills[el.getAttribute('data-skill')] = !!el.checked;
});
const persona = $('sa_persona')?.value || 'neutral';
const settings = {
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
base_url: $('sa_base_url')?.value || '',
[persona]: { skills },
};
genericRequest('AssistentSaveSettings', { settings }, () => {}, 0, () => {});
}
function applyConfigPayload(data, { applyDefaults = false } = {}) {
if (!data || data.error) {
return;
}
const prevPersona = state.config?.persona || $('sa_persona')?.value || '';
const prevControls = state.config?.control_values && typeof state.config.control_values === 'object'
? { ...state.config.control_values }
: null;
state.config = data;
if (data.exact && typeof data.exact === 'object') {
state.exact = data.exact;
}
const aspectSource = data.exact?.aspect_table || data.model?.aspect_table;
if (aspectSource && typeof aspectSource === 'object') {
applyAspectTableFrom(aspectSource);
}
const profileSource = data.exact?.profiles || data.model?.profiles;
if (profileSource && typeof profileSource === 'object') {
state.kreaProfiles = profileSource;
}
if (data.ui?.pack_aliases) {
PACK_ALIASES = { ...PACK_ALIASES, ...data.ui.pack_aliases };
}
if (data.ui?.welcome_html) {
WELCOME_HTML = data.ui.welcome_html;
}
if (data.ui?.help_text) {
HELP_TEXT = data.ui.help_text;
}
if (Array.isArray(data.ui?.slash) && data.ui.slash.length) {
SLASH_COMMANDS = data.ui.slash.map((s) => ({
cmd: s.cmd || '',
hint: s.hint || '',
action: s.action || '',
}));
}
if (Array.isArray(data.ui?.slash_extra) && data.ui.slash_extra.length) {
for (const s of data.ui.slash_extra) {
const cmd = s.cmd || '';
if (!cmd || SLASH_COMMANDS.some((c) => c.cmd === cmd)) {
continue;
}
SLASH_COMMANDS.push({
cmd,
hint: s.hint || '',
action: s.action || '',
});
}
}
if (data.ui?.help_extra) {
HELP_TEXT = `${HELP_TEXT || ''}\n\n${data.ui.help_extra}`.trim();
}
state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : [];
if (Array.isArray(data.personas)) {
state.personas = data.personas;
}
renderPersonaOptions(data.personas || [], data.persona || data.default_persona);
renderPackOptions(data.packs || [], applyDefaults ? data.assistant?.default_pack : null);
renderChips(data.ui?.chips || []);
renderSkillChecks(data.skills || [], state.enabledSkills);
if (applyDefaults && data.assistant?.default_pack && $('sa_pack') && !localStorage.getItem(LS_PACK)) {
$('sa_pack').value = data.assistant.default_pack;
}
if (data.assistant?.embed_model && !state.preferredEmbed) {
state.preferredEmbed = data.assistant.embed_model;
}
const asst = data.assistant || {};
if (asst.history_keep_turns != null) {
HISTORY_KEEP_TURNS = Math.max(1, Number(asst.history_keep_turns) || 4);
}
if (asst.max_ref_slots != null) {
MAX_REF_SLOTS = Math.max(1, Number(asst.max_ref_slots) || 4);
}
if (asst.max_gen_variants != null) {
MAX_GEN_VARIANTS = Math.max(2, Math.min(8, Number(asst.max_gen_variants) || 4));
}
if (asst.context_prompt_max != null) {
CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
}
if (asst.inventory_prompt_rich != null) {
INVENTORY_PROMPT_RICH = Math.max(4, Number(asst.inventory_prompt_rich) || 12);
}
if (asst.inventory_prompt_names != null) {
INVENTORY_PROMPT_NAMES = Math.max(INVENTORY_PROMPT_RICH, Number(asst.inventory_prompt_names) || 24);
}
fillKnobsFromConfig(data);
if (applyDefaults || data.exact) {
fillEmptyParamsFromExact();
}
const nextPersona = data.persona || $('sa_persona')?.value || '';
let controlValues = data.control_values || data.exact?.controls || {};
// Same persona refresh must not wipe in-flight slider drags / optimistic saves with disk defaults.
if (!applyDefaults && prevControls && nextPersona === prevPersona) {
controlValues = { ...controlValues, ...prevControls };
state.config.control_values = controlValues;
if (state.exact) {
state.exact.controls = { ...(state.exact.controls || {}), ...prevControls };
}
}
renderPersonaControls(data.controls || {}, controlValues);
syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $('sa_persona')?.value))?.source);
}
function syncPersonaDeleteButton(source) {
const btn = $('sa_persona_delete');
if (!btn) {
return;
}
const src = String(source || '');
const canDelete = src === 'overlay' || src === 'overlay+bundled';
btn.hidden = !canDelete;
btn.disabled = !canDelete;
}
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) {
box.hidden = true;
return;
}
box.hidden = false;
const ordered = keys.slice().sort((a, b) => {
const oa = Number(schema[a]?.order ?? 100);
const ob = Number(schema[b]?.order ?? 100);
if (oa !== ob) {
return oa - ob;
}
return String(a).localeCompare(String(b));
});
for (const id of ordered) {
const def = schema[id];
if (!def || typeof def !== 'object') {
continue;
}
if (String(def.type || 'slider').toLowerCase() !== 'slider') {
continue;
}
const min = Number(def.min ?? -1);
const max = Number(def.max ?? 1);
const step = Number(def.step ?? 0.05);
const defVal = Number(def.default ?? 0);
let cur = values && values[id] != null ? Number(values[id]) : defVal;
if (Number.isNaN(cur)) {
cur = defVal;
}
const asPercent = String(def.display || '').toLowerCase() === 'percent';
const fmt = (v) => (asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2));
const row = document.createElement('div');
row.className = 'sa-control-row';
row.title = def.hint || id;
const lab = document.createElement('label');
lab.textContent = def.label || id;
const input = document.createElement('input');
input.type = 'range';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(cur);
input.dataset.controlId = id;
const valEl = document.createElement('span');
valEl.className = 'sa-control-val';
valEl.textContent = fmt(cur);
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 = () => {
const v = Number(input.value);
applyLocal(v);
controlsPointerDown = false;
// Discard mid-drag rebuilds that carried stale server defaults — keep local values.
if (pendingControlsRender) {
const schema = pendingControlsRender.schema;
pendingControlsRender = null;
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; also persist on change for keyboard tweaks.
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 }), 50);
});
row.appendChild(lab);
row.appendChild(input);
row.appendChild(valEl);
box.appendChild(row);
}
}
function getControlValue(id, fallback) {
const v = state.config?.control_values?.[id] ?? state.exact?.controls?.[id];
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function coolDownHorny() {
const persona = $('sa_persona')?.value || '';
if (persona !== 'leonid') {
setStatus('/остынь только для Leonid');
return;
}
state.lastUserControlIntent = true;
const schema = state.config?.controls || {};
if (!schema.horny) {
setStatus('У этой личности нет слайдера Хорни');
return;
}
const min = Number(schema.horny.min ?? 0);
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));
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)}%`);
}
async function startHornyGame() {
const persona = $('sa_persona')?.value || '';
if (persona !== 'leonid') {
setStatus('/horny-game только для Leonid');
return;
}
const cur = getControlValue('horny', 35);
state.lastUserControlIntent = true;
await sendChat({
skipSlash: true,
skipAutoPack: true,
forcedUserText:
`Команда /horny-game. Текущий controls.horny = ${Math.round(cur)} (0100).\n` +
`Оцени, насколько вкусы пользователя в этом чате / последнем сообщении совпадают с твоими (roleplay, outfits, realism, fetishes).\n` +
`Поставь новый controls.horny: умножь/сдвинь текущее значение пропорционально «насколько тебе это зашло» ` +
`(слабое совпадение → чуть вниз или почти без изменений; сильное → заметный рост, clamp 0100).\n` +
`В прозе скажи кратко: совпало ли, какой множитель/сдвиг и новый %. ` +
`Обязателен JSON patch с "controls": { "horny": <number> }. Без generate, если не просили картинку.`,
});
setStatus('/horny-game…');
}
function savePersonaControls(partial) {
const persona = $('sa_persona')?.value || 'neutral';
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 || {} },
(data) => {
if (data?.error) {
setStatus(data.error);
return;
}
if (data?.control_values && state.config) {
state.config.control_values = data.control_values;
if (state.exact) {
state.exact.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,
() => setStatus('controls save failed'),
);
}
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) {
return;
}
const meta = (state.personas || []).find((p) => p.id === id);
const title = meta?.title || id;
const src = meta?.source || state.config?.persona_source || '';
if (src !== 'overlay' && src !== 'overlay+bundled') {
setStatus('Bundled personas cannot be deleted');
return;
}
if (!window.confirm(`Удалить «${title}»?\nПоставка (bundled) не трогается.`)) {
return;
}
await new Promise((resolve) => {
genericRequest(
'AssistentDeletePersona',
{ persona: id },
async (data) => {
if (data?.error) {
setStatus(data.error);
resolve();
return;
}
const next = data?.default_persona || 'neutral';
if (Array.isArray(data?.personas)) {
state.personas = data.personas;
}
renderPersonaOptions(state.personas || [], next);
if ($('sa_persona')) {
$('sa_persona').value = next;
}
await applyPersonaForChat(next, { quiet: false });
setStatus(`Удалено: ${id}`);
resolve();
},
0,
() => { setStatus('delete failed'); resolve(); },
);
});
}
function renderPersonaOptions(personas, selected) {
const sel = $('sa_persona');
if (!sel) {
return;
}
const cur = selected || sel.value || localStorage.getItem(LS_PERSONA) || 'neutral';
sel.innerHTML = '';
for (const p of personas) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.title || p.id;
if (p.accent) {
opt.dataset.accent = p.accent;
}
sel.appendChild(opt);
}
if ([...sel.options].some((o) => o.value === cur)) {
sel.value = cur;
}
const meta = (personas || []).find((p) => p.id === sel.value);
syncPersonaDeleteButton(meta?.source || state.config?.persona_source);
}
function renderPackOptions(packs, preferred) {
const sel = $('sa_pack');
if (!sel) {
return;
}
const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || defaultPackId();
sel.innerHTML = '';
const list = (packs || []).slice().sort((a, b) => (a.order || 100) - (b.order || 100));
for (const p of list) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.title || p.id;
sel.appendChild(opt);
}
if ([...sel.options].some((o) => o.value === cur)) {
sel.value = cur;
}
}
function renderChips(chips) {
const box = $('sa_chips');
if (!box || !Array.isArray(chips) || !chips.length) {
return;
}
box.innerHTML = '';
for (const c of chips) {
if (c.sep) {
const sep = document.createElement('span');
sep.className = 'sa-chip-sep';
sep.setAttribute('aria-hidden', 'true');
box.appendChild(sep);
continue;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sa-chip';
btn.textContent = c.label || c.value || '';
if (c.title) {
btn.title = c.title;
}
const action = c.action || '';
const value = c.value ?? '';
if (action === 'aspect') {
btn.setAttribute('data-aspect', value);
} else if (action === 'seed') {
btn.setAttribute('data-seed', value);
} else if (action === 'vary') {
btn.setAttribute('data-vary', value || '1');
} else if (action === 'krea_profile') {
btn.setAttribute('data-krea-profile', value);
}
box.appendChild(btn);
}
}
function renderSkillChecks(skills, enabled) {
const box = $('sa_skills_box');
if (!box) {
return;
}
const on = new Set(enabled || []);
box.innerHTML = '';
for (const s of skills || []) {
const label = document.createElement('label');
label.className = 'sa-check';
const input = document.createElement('input');
input.type = 'checkbox';
input.setAttribute('data-skill', s.id);
input.checked = on.has(s.id) || (!enabled?.length && !!s.default);
input.addEventListener('change', () => {
state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
saveSettings();
});
label.appendChild(input);
label.appendChild(document.createTextNode(` ${s.title || s.id}`));
box.appendChild(label);
}
state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
}
function loadConfig(persona, done) {
if (typeof genericRequest !== 'function') {
done?.(null);
return;
}
genericRequest(
'AssistentGetConfig',
{ persona: persona || $('sa_persona')?.value || 'neutral' },
(data) => {
applyConfigPayload(data, { applyDefaults: true });
done?.(data);
},
0,
() => done?.(null),
);
}
/** Larger param tags win (32b > 8b > 7b); instruct / qwen3 preferred over thinking/:latest. */
function chatModelSeniority(name) {
const n = String(name || '').toLowerCase();
let score = 0;
const m = n.match(/(?:^|[:\-/])(\d+)\s*b\b/);
if (m) {
score += Number(m[1]) * 1e6;
}
if (n.includes('instruct')) {
score += 5e4;
}
if (n.includes('qwen3')) {
score += 2e4;
}
if (n.includes('thinking') || n.endsWith(':latest')) {
score -= 1e4;
}
return score;
}
function pickSeniorChatModel(names) {
const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
if (!list.length) {
return '';
}
return [...list].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b))[0];
}
/**
* Resolve which chat tag to select / warm.
* Priority: api preferred (ollama-roles default_chat) → senior heuristic → LS (after one-shot junior upgrade).
*/
function resolveChatModel(names, apiPreferred) {
const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
if (!list.length) {
return '';
}
const preferred = apiPreferred && list.includes(apiPreferred)
? apiPreferred
: pickSeniorChatModel(list);
const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
if (!localStorage.getItem(LS_MODEL_SENIOR_MIG)) {
localStorage.setItem(LS_MODEL_SENIOR_MIG, '1');
if (preferred && (!ls || !list.includes(ls) || chatModelSeniority(ls) < chatModelSeniority(preferred))) {
state.preferredModel = preferred;
return preferred;
}
}
if (ls && list.includes(ls)) {
return ls;
}
return preferred || list[0];
}
function setModelOptions(models, { error, preferred } = {}) {
const sel = $('sa_model');
const sel2 = $('sa_settings_chat_model');
const apply = (target) => {
if (!target) {
return;
}
let names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
names = [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b));
target.innerHTML = '';
if (error) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
target.appendChild(opt);
target.disabled = true;
return;
}
target.disabled = false;
if (!names.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No Ollama models — pull / Refresh';
target.appendChild(opt);
return;
}
for (const name of names) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
target.appendChild(opt);
}
const pick = resolveChatModel(names, preferred);
if (pick) {
target.value = pick;
}
};
apply(sel);
apply(sel2);
}
function setEmbedModelOptions(models) {
const sel = $('sa_embed_model');
if (!sel) {
return;
}
const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
sel.innerHTML = '';
if (!names.length) {
const opt = document.createElement('option');
opt.value = state.preferredEmbed || 'nomic-embed-text';
opt.textContent = opt.value + ' (ожидается pull)';
sel.appendChild(opt);
return;
}
for (const name of names) {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
sel.appendChild(opt);
}
const prefer = state.preferredEmbed || localStorage.getItem(LS_EMBED) || state.config?.assistant?.embed_model;
if (prefer && names.includes(prefer)) {
sel.value = prefer;
} else if (prefer && !names.includes(prefer)) {
const opt = document.createElement('option');
opt.value = prefer;
opt.textContent = prefer;
sel.appendChild(opt);
sel.value = prefer;
}
}
function refreshModels() {
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
setStatus('Loading models…');
if (typeof genericRequest !== 'function') {
setStatus('SwarmUI API not ready');
setModelOptions([], { error: 'SwarmUI API not ready' });
return;
}
genericRequest(
'AssistentListModels',
{ baseUrl },
(data) => {
const models = data.models || [];
const memoryModels = data.memory_models || [];
const preferred = (data.preferred || '').trim();
setModelOptions(models, { preferred });
setEmbedModelOptions(memoryModels);
const pick = resolveChatModel(models, preferred);
if (pick && $('sa_model')) {
$('sa_model').value = pick;
if ($('sa_settings_chat_model')) {
$('sa_settings_chat_model').value = pick;
}
state.preferredModel = pick;
localStorage.setItem(LS_MODEL, pick);
}
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
if (models.length) {
setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`);
} else {
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
}
saveSettings();
},
0,
(err) => {
const msg = String(err || 'Ollama unreachable');
setStatus(msg);
setModelOptions([], { error: msg });
setOllamaHealth('down', 'Ollama ✕', msg);
appendMessage('error', msg);
},
);
}
function refreshInventory(done, opts = {}) {
if (typeof genericRequest !== 'function') {
if (done) {
done();
}
return;
}
const rescan = !!opts.rescan;
genericRequest(
'AssistentListInventory',
{ rescan },
(data) => {
state.inventory = {
loras: data.loras || [],
checkpoints: data.checkpoints || [],
wildcards: data.wildcards || [],
has_civitai_key: !!data.has_civitai_key,
inventory_at: data.inventory_at || Math.floor(Date.now() / 1000),
rescanned: !!data.rescanned,
};
state.inventoryFetchedAt = Date.now();
const n = state.inventory.loras.length;
const ck = state.inventory.checkpoints.length;
setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? ' (rescanned)' : ''}`);
prefetchActiveModelCards();
if (state.view === 'cards') {
renderCardsList();
}
if (done) {
done(state.inventory);
}
},
0,
(err) => {
console.warn('Assistent inventory', err);
if (done) {
done(null);
}
},
);
}
function refreshInventoryAsync(opts = {}) {
return new Promise((resolve) => refreshInventory(resolve, opts));
}
function memoryKindFilter() {
return $('sa_mem_kind')?.value || 'all';
}
function memoryScopeFilter() {
return $('sa_mem_scope')?.value || 'all';
}
function memorySearchFilter() {
return ($('sa_mem_search')?.value || '').trim().toLowerCase();
}
function renderMemoryKinds(kinds) {
const sel = $('sa_mem_kind');
if (!sel) {
return;
}
const cur = sel.value || 'all';
sel.innerHTML = '';
const all = document.createElement('option');
all.value = 'all';
all.textContent = 'Все типы';
sel.appendChild(all);
for (const kind of kinds || []) {
const opt = document.createElement('option');
opt.value = kind;
opt.textContent = kind;
sel.appendChild(opt);
}
if ([...sel.options].some((o) => o.value === cur)) {
sel.value = cur;
}
}
function filteredMemoryRows() {
const filter = memoryKindFilter();
const scope = memoryScopeFilter();
const q = memorySearchFilter();
const persona = $('sa_persona')?.value || 'neutral';
return (state.memoryRows || []).filter((m) => {
if (filter !== 'all' && m.kind !== filter) {
return false;
}
if (scope === 'shared' && m.scope !== 'shared') {
return false;
}
if (scope === 'personal' && !(m.scope === 'personal' && (m.persona === persona || !m.persona))) {
return false;
}
if (q) {
const hay = `${m.kind || ''} ${m.key || ''} ${m.text || ''}`.toLowerCase();
if (!hay.includes(q)) {
return false;
}
}
return true;
});
}
function renderMemoryList() {
const root = $('sa_mem_list');
if (!root) {
return;
}
const rows = filteredMemoryRows();
root.innerHTML = '';
if (!rows.length) {
root.innerHTML = '<div class="sa-mem-empty">Крафт-память пуста — карточки, seed и патчи <code>memory_upsert</code>.</div>';
return;
}
for (const row of rows) {
const el = document.createElement('div');
el.className = 'sa-mem-row';
const bundled = row.source === 'bundled';
const when = row.updated ? formatChatWhen(row.updated * 1000) : '';
const scope = row.scope === 'personal' ? `персона ${row.persona || '—'}` : 'общая';
el.innerHTML = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-kind-badge">${escapeHtml(row.kind || 'note')}</span><span class="sa-mem-row-key">${escapeHtml(row.key || '')}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 220))}</div><div class="sa-mem-row-meta">${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}</div></div>`;
const forget = document.createElement('button');
forget.type = 'button';
forget.className = 'basic-button sa-mem-forget';
forget.textContent = '×';
if (bundled) {
forget.disabled = true;
forget.title = 'Bundled — вернётся при reseed, правь Config/_base/memory-seed/';
} else {
forget.title = 'Забыть';
forget.addEventListener('click', () => forgetMemory(row));
}
el.appendChild(forget);
root.appendChild(el);
}
}
function refreshMemoryList() {
if (typeof genericRequest !== 'function') {
return;
}
const list = $('sa_mem_list');
if (list && !state.memoryRows.length) {
list.innerHTML = '<div class="sa-mem-empty">Читаю память…</div>';
}
genericRequest(
'AssistentListMemory',
{ limit: 200 },
(data) => {
state.memoryRows = Array.isArray(data?.memories) ? data.memories : [];
renderMemoryKinds(data?.kinds || []);
renderMemoryList();
const foot = $('sa_mem_total');
if (foot) {
foot.textContent = `Всего: ${data?.total ?? state.memoryRows.length} · ${data?.embed_model || '—'}`;
}
},
0,
(err) => {
if (list) {
list.innerHTML = `<div class="sa-mem-empty">Память недоступна: ${escapeHtml(String(err || 'ошибка'))}</div>`;
}
},
);
}
function forgetMemory(row) {
if (!row?.kind || !row?.key || typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentForgetMemory',
{
kind: row.kind,
key: row.key,
source: row.source || '',
scope: row.scope || '',
persona: row.scope === 'personal' ? (row.persona || '') : '',
},
() => {
state.memoryRows = (state.memoryRows || []).filter((m) => !(m.kind === row.kind && m.key === row.key && m.source === row.source && m.persona === row.persona));
renderMemoryList();
setStatus(`Забыто: ${row.kind}/${row.key}`);
},
0,
(err) => setStatus(String(err || 'Не удалось забыть')),
);
}
function clearCraftMemory(opts = {}) {
if (typeof genericRequest !== 'function') {
return;
}
const label = opts.label || 'крафт-память';
if (!window.confirm(`Очистить ${label}? Bundled seed останется.`)) {
return;
}
const body = {
scope: opts.scope || '',
kind: opts.kind || '',
persona: opts.persona || '',
};
genericRequest(
'AssistentClearMemory',
body,
(data) => {
setStatus(`Удалено крафт-записей: ${data?.deleted ?? 0}`);
refreshMemoryList();
},
0,
(err) => setStatus(String(err || 'Очистка не удалась')),
);
}
function setSettingsTab(id) {
state.settingsTab = id || 'behavior';
document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
const on = btn.getAttribute('data-stab') === state.settingsTab;
btn.classList.toggle('sa-stab-active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('#sa_settings .sa-spane').forEach((pane) => {
pane.hidden = pane.getAttribute('data-spane') !== state.settingsTab;
});
if (state.settingsTab === 'craft') {
refreshMemoryList();
refreshWantedQueue();
}
if (state.settingsTab === 'user') {
refreshUserPrefs();
}
if (state.settingsTab === 'personas') {
renderPersonaSettingsList();
}
if (state.settingsTab === 'models') {
syncSettingsHealthLine();
const m = $('sa_model')?.value;
if (m && $('sa_settings_chat_model')) {
$('sa_settings_chat_model').value = m;
}
}
if (state.settingsTab === 'more') {
fillKnobsFromConfig(state.config);
}
}
function fillKnobsFromConfig(data) {
const asst = data?.assistant || state.config?.assistant || {};
const exact = data?.exact || state.config?.exact || state.exact || {};
const setNum = (id, v) => {
const el = $(id);
if (el && v != null && Number.isFinite(Number(v))) {
el.value = String(v);
}
};
setNum('sa_num_ctx', asst.num_ctx);
setNum('sa_history_keep', asst.history_keep_turns);
setNum('sa_memory_top_k', asst.memory_top_k);
const w = asst.user_prefs_weight != null ? Number(asst.user_prefs_weight) : 1;
const weightEl = $('sa_user_prefs_weight');
if (weightEl) {
weightEl.value = String(Math.max(0, Math.min(1.5, w)));
const lab = $('sa_user_prefs_weight_val');
if (lab) {
lab.textContent = Number(weightEl.value).toFixed(1);
}
}
const turbo = exact.profiles?.turbo || {};
const raw = exact.profiles?.raw || {};
setNum('sa_exact_turbo_steps', turbo.steps);
setNum('sa_exact_turbo_cfg', turbo.cfg);
setNum('sa_exact_turbo_sigma', turbo.sigma_shift);
setNum('sa_exact_raw_steps', raw.steps);
setNum('sa_exact_raw_cfg', raw.cfg);
setNum('sa_exact_raw_sigma', raw.sigma_shift);
}
function saveKnobs() {
if (typeof genericRequest !== 'function') {
return;
}
const num = (id) => {
const v = parseFloat($(id)?.value);
return Number.isFinite(v) ? v : null;
};
const assistant = {
num_ctx: num('sa_num_ctx'),
history_keep_turns: num('sa_history_keep'),
memory_top_k: num('sa_memory_top_k'),
user_prefs_weight: num('sa_user_prefs_weight'),
};
Object.keys(assistant).forEach((k) => {
if (assistant[k] == null) {
delete assistant[k];
}
});
const exact = {
profiles: {
turbo: {
steps: num('sa_exact_turbo_steps'),
cfg: num('sa_exact_turbo_cfg'),
sigma_shift: num('sa_exact_turbo_sigma'),
},
raw: {
steps: num('sa_exact_raw_steps'),
cfg: num('sa_exact_raw_cfg'),
sigma_shift: num('sa_exact_raw_sigma'),
},
},
};
genericRequest(
'AssistentSaveKnobs',
{ assistant, exact },
(data) => {
if (data?.assistant || data?.exact) {
applyConfigPayload({
...state.config,
assistant: data.assistant || state.config?.assistant,
exact: data.exact || state.config?.exact,
});
}
setStatus('Knobs сохранены в overlay');
},
0,
(err) => setStatus(String(err || 'Не удалось сохранить knobs')),
);
}
function syncSettingsHealthLine() {
const line = $('sa_settings_health_line');
const badge = $('sa_ollama_health');
if (line && badge) {
line.textContent = badge.textContent || 'Ollama · …';
line.className = 'sa-settings-health ' + (badge.className || '').replace('sa-health', '').trim();
}
}
function personaSourceLabel(source) {
if (source === 'overlay') {
return 'моя';
}
if (source === 'overlay+bundled') {
return 'встроено+правка';
}
return 'встроено';
}
function renderPersonaSettingsList() {
const root = $('sa_persona_list');
if (!root) {
return;
}
const list = state.personas || state.config?.personas || [];
const cur = state.settingsPersonaId || $('sa_persona')?.value || list[0]?.id;
state.settingsPersonaId = cur;
root.innerHTML = '';
for (const p of list) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sa-persona-item' + (p.id === cur ? ' sa-persona-item-active' : '');
const accent = p.accent || 'currentColor';
btn.innerHTML = `<div class="sa-persona-item-title"><span class="sa-persona-dot" style="background:${escapeHtml(accent)}"></span><span>${escapeHtml(p.title || p.id)}</span></div><div class="sa-persona-badge">${escapeHtml(personaSourceLabel(p.source))}</div>`;
btn.addEventListener('click', () => {
state.settingsPersonaId = p.id;
renderPersonaSettingsList();
loadPersonaPreview(p.id);
});
root.appendChild(btn);
}
syncPersonaPanelActions();
if (cur) {
loadPersonaPreview(cur);
}
}
function syncPersonaPanelActions() {
const id = state.settingsPersonaId;
const p = (state.personas || []).find((x) => x.id === id);
const canDelete = p && (p.source === 'overlay' || p.source === 'overlay+bundled');
const del = $('sa_btn_persona_delete_panel');
if (del) {
del.disabled = !canDelete;
}
}
function loadPersonaPreview(id) {
const box = $('sa_persona_preview');
if (!box || typeof genericRequest !== 'function') {
return;
}
box.innerHTML = '<div class="sa-mem-empty">Загрузка…</div>';
genericRequest(
'AssistentGetPersonaShelves',
{ persona: id },
(data) => {
const summary = data?.identity_summary || '';
const src = data?.source || '';
box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
syncPersonaPanelActions();
},
0,
(err) => {
box.innerHTML = `<div class="sa-mem-empty">${escapeHtml(String(err || 'ошибка'))}</div>`;
},
);
}
function exportSelectedPersona() {
const id = state.settingsPersonaId || $('sa_persona')?.value;
if (!id || typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentExportPersona',
{ persona: id },
(data) => {
const pack = data?.pack;
if (!pack) {
setStatus('Пустой экспорт');
return;
}
const blob = new Blob([JSON.stringify(pack, null, 2)], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${pack.id || id}.assistent-persona.json`;
a.click();
URL.revokeObjectURL(a.href);
setStatus(`Экспорт: ${a.download}`);
},
0,
(err) => setStatus(String(err || 'Экспорт не удался')),
);
}
function importPersonaFile(file) {
if (!file || typeof genericRequest !== 'function') {
return;
}
const reader = new FileReader();
reader.onload = () => {
let pack;
try {
pack = JSON.parse(String(reader.result || ''));
} catch (e) {
setStatus('Невалидный JSON');
return;
}
let newId = pack?.id || '';
if ((state.personas || []).some((p) => p.id === newId && (p.source === 'bundled' || p.source === 'overlay+bundled'))) {
newId = window.prompt('Id занят bundled — новый id:', `${newId}_import`) || '';
}
genericRequest(
'AssistentImportPersona',
{ pack, new_id: newId || null, overwrite: false },
(data) => {
if (Array.isArray(data?.personas)) {
state.personas = data.personas;
renderPersonaOptions(data.personas, data.persona?.id);
}
state.settingsPersonaId = data?.persona?.id || newId;
renderPersonaSettingsList();
setStatus(`Импорт: ${data?.persona?.id || newId}`);
},
0,
(err) => setStatus(String(err || 'Импорт не удался')),
);
};
reader.readAsText(file);
}
function cloneSelectedPersona() {
const from = state.settingsPersonaId || $('sa_persona')?.value;
if (!from) {
return;
}
const to = window.prompt('Новый id личности:', `${from}_copy`);
if (!to) {
return;
}
genericRequest(
'AssistentClonePersona',
{ from, to, title: to },
(data) => {
if (Array.isArray(data?.personas)) {
state.personas = data.personas;
renderPersonaOptions(data.personas, to);
}
state.settingsPersonaId = to;
renderPersonaSettingsList();
setStatus(`Клон: ${to}`);
},
0,
(err) => setStatus(String(err || 'Клон не удался')),
);
}
function deleteSelectedOverlayPersona() {
const id = state.settingsPersonaId;
const p = (state.personas || []).find((x) => x.id === id);
if (!p || (p.source !== 'overlay' && p.source !== 'overlay+bundled')) {
setStatus('Можно удалить только overlay');
return;
}
if (!window.confirm(`Удалить overlay-личность «${id}»?`)) {
return;
}
genericRequest(
'AssistentDeletePersona',
{ persona: id },
(data) => {
if (Array.isArray(data?.personas)) {
state.personas = data.personas;
renderPersonaOptions(data.personas, data.default_persona);
}
state.settingsPersonaId = data?.default_persona || null;
renderPersonaSettingsList();
setStatus(`Удалено: ${id}`);
},
0,
(err) => setStatus(String(err || 'Удаление не удалось')),
);
}
function refreshUserPrefs() {
if (typeof genericRequest !== 'function') {
return;
}
const persona = $('sa_persona')?.value || 'neutral';
genericRequest(
'AssistentListUserPrefs',
{ persona, limit: 200 },
(data) => {
state.userPrefs = Array.isArray(data?.prefs) ? data.prefs : [];
renderUserPrefsLists();
},
0,
(err) => setStatus(String(err || 'User prefs недоступны')),
);
}
function renderUserPrefsLists() {
const persona = $('sa_persona')?.value || 'neutral';
const global = (state.userPrefs || []).filter((p) => p.scope === 'global');
const personal = (state.userPrefs || []).filter((p) => p.scope === 'persona' && (p.persona_id === persona || p.persona === persona));
const fill = (rootId, rows) => {
const root = $(rootId);
if (!root) {
return;
}
root.innerHTML = '';
if (!rows.length) {
root.innerHTML = '<div class="sa-mem-empty">Пусто</div>';
return;
}
for (const row of rows) {
const el = document.createElement('div');
el.className = 'sa-mem-row';
const pin = row.pinned ? ' ★' : '';
el.innerHTML = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-row-key">${escapeHtml(row.key || '')}${pin}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 200))}</div></div>`;
el.querySelector('.sa-mem-row-body')?.addEventListener('click', () => editUserPref(row));
el.querySelector('.sa-mem-row-body')?.setAttribute('title', 'Клик — редактировать');
const pinBtn = document.createElement('button');
pinBtn.type = 'button';
pinBtn.className = 'basic-button sa-mem-forget';
pinBtn.textContent = row.pinned ? '★' : '☆';
pinBtn.title = row.pinned ? 'Unpin' : 'Pin';
pinBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleUserPrefPin(row);
});
el.appendChild(pinBtn);
const forget = document.createElement('button');
forget.type = 'button';
forget.className = 'basic-button sa-mem-forget';
forget.textContent = '×';
forget.title = 'Забыть';
forget.addEventListener('click', (e) => {
e.stopPropagation();
forgetUserPref(row);
});
el.appendChild(forget);
root.appendChild(el);
}
};
fill('sa_prefs_global', global);
fill('sa_prefs_persona', personal);
}
function editUserPref(row) {
const text = window.prompt('Текст факта:', row.text || '');
if (text == null || !String(text).trim()) {
return;
}
genericRequest(
'AssistentUpsertUserPref',
{
key: row.key,
text: String(text).trim(),
scope: row.scope || 'global',
persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
source: 'user',
pinned: !!row.pinned,
},
() => refreshUserPrefs(),
0,
(err) => setStatus(String(err || 'Не удалось сохранить')),
);
}
function toggleUserPrefPin(row) {
genericRequest(
'AssistentUpsertUserPref',
{
key: row.key,
text: row.text,
scope: row.scope || 'global',
persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
source: row.source || 'user',
pinned: !row.pinned,
},
() => refreshUserPrefs(),
0,
(err) => setStatus(String(err || 'Не удалось pin')),
);
}
function addUserPref(scope) {
const key = window.prompt('Ключ (stable-id):', scope === 'global' ? 'prefer' : 'tone');
if (!key) {
return;
}
const text = window.prompt('Текст факта:', '');
if (!text) {
return;
}
genericRequest(
'AssistentUpsertUserPref',
{
key: key.trim(),
text: text.trim(),
scope,
persona: $('sa_persona')?.value || 'neutral',
source: 'user',
pinned: false,
},
() => {
refreshUserPrefs();
setStatus('Сохранено');
},
0,
(err) => setStatus(String(err || 'Не удалось сохранить')),
);
}
function forgetUserPref(row) {
genericRequest(
'AssistentForgetUserPref',
{
key: row.key,
scope: row.scope || 'global',
persona: row.persona_id || row.persona || $('sa_persona')?.value,
},
() => refreshUserPrefs(),
0,
(err) => setStatus(String(err || 'Не удалось забыть')),
);
}
function clearUserPrefs(scope) {
const labels = { global: 'общие prefs', persona: 'prefs этой личности', all: 'все prefs о пользователе' };
if (!window.confirm(`Очистить ${labels[scope] || scope}?`)) {
return;
}
genericRequest(
'AssistentClearUserPrefs',
{ scope, persona: $('sa_persona')?.value || 'neutral' },
(data) => {
setStatus(`Удалено: ${data?.deleted ?? 0}`);
refreshUserPrefs();
},
0,
(err) => setStatus(String(err || 'Очистка не удалась')),
);
}
function resetUiState() {
if (!window.confirm('Сбросить UI-state (local + disk)? Настройки Ollama и prefs останутся.')) {
return;
}
const keys = Object.keys(localStorage).filter((k) => k.startsWith('swarm_assistent_'));
for (const k of keys) {
if (k === LS_TASTE) {
continue;
}
localStorage.removeItem(k);
}
diskPersist()?.saveUiState?.({});
setStatus('UI-state сброшен — обнови страницу');
}
function modelKeyLeaf(name) {
return String(name || '')
.replace(/\\/g, '/')
.split('/')
.pop()
.replace(/\.(safetensors|ckpt|pt|pth|gguf|bin)$/i, '')
.trim()
.toLowerCase();
}
function refreshWantedQueue() {
if (typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentListWanted',
{},
(data) => {
const items = Array.isArray(data?.items) ? data.items : [];
state.wanted = { count: data?.count ?? items.length, items };
const keys = new Set();
for (const item of items) {
const leaf = modelKeyLeaf(item?.title);
if (leaf) {
keys.add(leaf);
}
if (item?.version_id) {
keys.add(`v${item.version_id}`);
}
}
state.wantedKeys = keys;
const el = $('sa_mem_wanted');
if (el) {
el.textContent = state.wanted.count
? `Очередь wanted: ${state.wanted.count} (скачается на следующем up)`
: 'Очередь wanted: пусто';
el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join('\n');
}
if (state.view === 'cards') {
renderCardsList();
}
},
0,
() => {
const el = $('sa_mem_wanted');
if (el) {
el.textContent = 'Очередь wanted: —';
}
},
);
}
function isWantedModel(row) {
const keys = state.wantedKeys;
if (!keys || !keys.size) {
return false;
}
for (const candidate of [row?.name, row?.title]) {
const leaf = modelKeyLeaf(candidate);
if (leaf && keys.has(leaf)) {
return true;
}
}
return false;
}
function setOllamaHealth(level, text, title) {
state.ollamaHealth = level;
const el = $('sa_ollama_health');
if (!el) {
return;
}
el.hidden = false;
el.textContent = text;
el.title = title || text;
el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
el.classList.add(`sa-health-${level}`);
syncSettingsHealthLine();
}
function probeOllamaHealth() {
if (typeof genericRequest !== 'function') {
return;
}
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
genericRequest(
'AssistentListModels',
{ baseUrl },
(data) => {
if (data?.error) {
setOllamaHealth('down', 'Ollama ✕', String(data.error));
return;
}
const chat = (data.models || []).length;
const mem = (data.memory_models || []).length;
if (!chat) {
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
return;
}
setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
},
0,
(err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`),
);
}
function setCardStatus(msg) {
const el = $('sa_card_status');
if (el) {
el.textContent = msg || '';
}
}
function setView(view) {
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.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) {
state.personas = data.personas;
}
});
}
function prefetchCard(kind, name) {
return new Promise((resolve) => {
if (!kind || !name || typeof genericRequest !== 'function') {
resolve(null);
return;
}
const key = `${kind}:${name}`;
genericRequest(
'AssistentGetCard',
{ kind, name },
(data) => {
if (data?.card) {
state.modelCards[key] = data.card;
}
resolve(data?.card || null);
},
0,
() => resolve(null),
);
});
}
async function prefetchActiveModelCards() {
const keys = [];
const seen = new Set();
const add = (kind, name) => {
if (!kind || !name) {
return;
}
const key = `${kind}:${name}`;
if (seen.has(key)) {
return;
}
seen.add(key);
keys.push({ kind, name });
};
try {
const ck = resolveCurrentCheckpoint();
if (ck?.name) {
add('checkpoint', ck.name);
}
} catch (e) { /* ignore */ }
try {
if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
for (const l of loraHelper.selected) {
add('lora', l?.name || l);
}
}
} catch (e) { /* ignore */ }
for (const l of state.inventory?.loras || []) {
if (l?.has_card) {
add('lora', l.name);
}
if (keys.length >= 14) {
break;
}
}
for (const c of state.inventory?.checkpoints || []) {
if (c?.has_card) {
add('checkpoint', c.name);
}
if (keys.length >= 16) {
break;
}
}
await Promise.all(keys.map((k) => prefetchCard(k.kind, k.name)));
}
function cardsCatalog() {
const kind = $('sa_cards_kind')?.value || 'all';
const inv = state.inventory || {};
const rows = [];
if (kind === 'all' || kind === 'checkpoint') {
for (const c of inv.checkpoints || []) {
rows.push({
kind: 'checkpoint',
name: c.name,
title: c.title || c.name,
has_card: !!c.has_card,
hash: c.hash || '',
preview_url: c.preview_url || null,
has_sidecar: !!c.has_sidecar,
});
}
}
if (kind === 'all' || kind === 'lora') {
for (const l of inv.loras || []) {
rows.push({
kind: 'lora',
name: l.name,
title: l.title || l.name,
has_card: !!l.has_card,
trigger: l.trigger_phrase,
hash: l.hash || '',
preview_url: l.preview_url || null,
has_sidecar: !!l.has_sidecar,
});
}
}
return rows;
}
function renderCardsList() {
const root = $('sa_cards_list');
if (!root) {
return;
}
root.innerHTML = '';
const rows = cardsCatalog();
if (!rows.length) {
root.innerHTML = '<div class="sa-chat-empty-hint">Inventory пуст — Обновить.</div>';
return;
}
for (const row of rows) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sa-card-row';
if (state.cardsSelection && state.cardsSelection.kind === row.kind && state.cardsSelection.name === row.name) {
btn.classList.add('sa-selected');
}
const thumb = row.preview_url
? `<img class="sa-card-row-thumb" src="${escapeHtml(row.preview_url)}" alt="" />`
: '<div class="sa-card-row-thumb"></div>';
const metaBits = [];
metaBits.push(row.has_card ? 'card ✓' : 'нет card');
if (row.has_sidecar) {
metaBits.push('sidecar');
}
if (isWantedModel(row)) {
metaBits.push('⏳ wanted');
btn.classList.add('sa-card-row-wanted');
}
if (row.trigger) {
metaBits.push(String(row.trigger).slice(0, 40));
}
btn.innerHTML = `${thumb}<div class="sa-card-row-body"><div class="sa-card-row-kind">${escapeHtml(row.kind)}</div><div class="sa-card-row-name">${escapeHtml(row.title || row.name)}</div><div class="sa-card-row-meta">${escapeHtml(metaBits.join(' · '))}</div></div><button type="button" class="basic-button sa-card-row-chat" data-chat="1" title="В чат">↗</button>`;
btn.addEventListener('click', (e) => {
if (e.target?.closest?.('[data-chat]')) {
e.preventDefault();
e.stopPropagation();
sendCardToChat(row);
return;
}
selectCardModel(row);
});
root.appendChild(btn);
}
}
function sendCardToChat(row) {
if (!row?.name) {
return;
}
selectCardModel(row);
setView('chat');
const kind = row.kind === 'checkpoint' ? 'checkpoint' : 'LoRA';
const triggers = row.trigger ? ` Triggers: ${row.trigger}.` : '';
if ($('sa_input')) {
$('sa_input').value = `Используй ${kind} «${row.name}».${triggers} Учти карточку/triggers и предложи патч.`;
$('sa_input').focus();
}
setStatus(`В чат → ${row.name}`);
}
function wireCardForm() {
const sync = () => {
if ($('sa_card_show_json')?.checked) {
syncCardJsonFromForm();
}
};
['sa_card_triggers', 'sa_card_weight', 'sa_card_when', 'sa_card_avoid', 'sa_card_hint', 'sa_card_notes', 'sa_card_url']
.forEach((id) => $(id)?.addEventListener('change', sync));
$('sa_card_show_json')?.addEventListener('change', () => {
const on = !!$('sa_card_show_json')?.checked;
const ta = $('sa_card_json');
if (ta) {
ta.hidden = !on;
if (on) {
syncCardJsonFromForm();
}
}
});
$('sa_card_json')?.addEventListener('change', () => {
if ($('sa_card_show_json')?.checked) {
applyCardToForm(readCardDraft() || {});
}
});
}
function applyCardToForm(card) {
card = card || {};
const triggers = Array.isArray(card.triggers) ? card.triggers.join(', ') : (card.triggers || '');
if ($('sa_card_triggers')) {
$('sa_card_triggers').value = triggers;
}
if ($('sa_card_weight')) {
$('sa_card_weight').value = card.weight != null ? card.weight : (state.cardsSelection?.kind === 'lora' ? 0.8 : 1);
}
if ($('sa_card_when')) {
$('sa_card_when').value = card.when || '';
}
if ($('sa_card_avoid')) {
$('sa_card_avoid').value = card.avoid || '';
}
if ($('sa_card_hint')) {
$('sa_card_hint').value = card.prompt_hint || '';
}
if ($('sa_card_notes')) {
$('sa_card_notes').value = card.notes || '';
}
if ($('sa_card_url')) {
$('sa_card_url').value = card.civitai_url || '';
}
if ($('sa_card_json')) {
$('sa_card_json').value = JSON.stringify(card, null, 2);
}
}
function syncCardJsonFromForm() {
const sel = state.cardsSelection || {};
let base = {};
try {
base = JSON.parse($('sa_card_json')?.value || '{}');
} catch (e) {
base = {};
}
const triggers = String($('sa_card_triggers')?.value || '')
.split(/[,;]/)
.map((s) => s.trim())
.filter(Boolean);
const card = {
...base,
kind: sel.kind || base.kind || 'lora',
name: sel.name || base.name || '',
triggers,
weight: parseFloat($('sa_card_weight')?.value || '0.8') || 0.8,
when: $('sa_card_when')?.value || '',
avoid: $('sa_card_avoid')?.value || '',
prompt_hint: $('sa_card_hint')?.value || '',
notes: $('sa_card_notes')?.value || '',
civitai_url: $('sa_card_url')?.value || '',
version_id: base.version_id != null ? base.version_id : null,
};
if ($('sa_card_json')) {
$('sa_card_json').value = JSON.stringify(card, null, 2);
}
return card;
}
function renderCardPreviews(urls) {
const root = $('sa_card_previews');
if (!root) {
return;
}
const list = (urls || []).filter(Boolean).slice(0, 6);
root.innerHTML = '';
if (!list.length) {
root.hidden = true;
return;
}
root.hidden = false;
for (const url of list) {
const img = document.createElement('img');
img.className = 'sa-card-thumb';
img.src = url;
img.alt = 'preview';
img.title = 'Клик — на вкладку Refs';
img.addEventListener('click', () => {
setBoardTab('refs');
addRefFromUrl(url);
setStatus('Превью → Refs');
});
root.appendChild(img);
}
}
function mergeCivitaiIntoCard(card, data) {
const out = { ...(card || {}) };
const civ = data?.civitai;
if (!out.triggers?.length && data?.trigger_phrase) {
out.triggers = [data.trigger_phrase];
}
if (civ) {
const trained = civ.trainedWords || civ.trained_words;
if ((!out.triggers || !out.triggers.length) && Array.isArray(trained) && trained.length) {
out.triggers = trained.slice(0, 12);
}
if (!out.civitai_url) {
const mid = civ.modelId || civ.model?.id || civ.model?.modelId;
const vid = civ.id || data.version_id;
if (mid && vid) {
out.civitai_url = `https://civitai.red/models/${mid}?modelVersionId=${vid}`;
} else if (vid) {
out.civitai_url = `https://civitai.red/models/0?modelVersionId=${vid}`;
}
}
if (out.version_id == null && (civ.id || data.version_id)) {
out.version_id = civ.id || data.version_id;
}
if (!out.notes && civ.description) {
out.notes = String(civ.description).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400);
}
}
if (out.version_id == null && data?.version_id) {
out.version_id = data.version_id;
}
return out;
}
function formatMetaStatus(data) {
if (!data) {
return 'Нет ответа';
}
if (data.error) {
return String(data.error);
}
const parts = [];
if (data.has_sidecar) {
parts.push(`Сидикарь ✓ · version ${data.version_id || '?'}`);
} else if (data.fetched) {
parts.push(`Civitai ✓ · version ${data.version_id || '?'}`);
} else {
parts.push('Сидикаря нет');
}
const n = (data.example_urls || data.preview_urls || []).length;
if (n) {
parts.push(`${n} кадр${n === 1 ? '' : 'а'}`);
}
if (data.has_card) {
parts.push('карточка Assistent ✓');
} else {
parts.push('карточки Assistent нет');
}
if (data.fetch_error) {
parts.push(String(data.fetch_error));
}
return parts.join(' · ');
}
function applyCardMetaResponse(row, data, { preserveUser } = {}) {
let card = data.card || {
kind: row.kind,
name: row.name,
triggers: data.trigger_phrase ? [data.trigger_phrase] : [],
weight: row.kind === 'lora' ? 0.8 : 1,
when: '',
avoid: '',
prompt_hint: '',
notes: '',
civitai_url: '',
version_id: data.version_id || null,
};
if (preserveUser) {
const current = syncCardJsonFromForm();
card = {
...mergeCivitaiIntoCard(card, data),
when: current.when || card.when || '',
avoid: current.avoid || card.avoid || '',
prompt_hint: current.prompt_hint || card.prompt_hint || '',
notes: current.notes || card.notes || '',
};
} else {
card = mergeCivitaiIntoCard(card, data);
}
applyCardToForm(card);
state.modelCards[`${row.kind}:${row.name}`] = card;
const urls = [
...(data.preview_urls || []),
...(data.example_urls || []),
];
renderCardPreviews(urls);
const badge = $('sa_card_badge');
if (badge) {
badge.hidden = false;
badge.textContent = data.has_card ? 'card ✓' : (data.has_sidecar || data.fetched ? 'meta ✓' : 'нет меты');
}
setCardStatus(formatMetaStatus(data));
}
function selectCardModel(row) {
state.cardsSelection = row;
renderCardsList();
if ($('sa_card_title')) {
$('sa_card_title').textContent = row.title || row.name;
}
const badge = $('sa_card_badge');
if (badge) {
badge.hidden = false;
badge.textContent = '…';
}
setCardStatus('Читаю локальную мету…');
genericRequest(
'AssistentGetCardMeta',
{ kind: row.kind, name: row.name, fetch: false },
(data) => applyCardMetaResponse(row, data || {}),
0,
(err) => setCardStatus(String(err || 'Ошибка загрузки')),
);
}
function fetchCardMetaLive() {
const row = state.cardsSelection;
if (!row) {
setCardStatus('Выбери модель');
return;
}
setCardStatus('Сидикаря нет · ищу по SHA…');
genericRequest(
'AssistentGetCardMeta',
{ kind: row.kind, name: row.name, fetch: true },
(data) => applyCardMetaResponse(row, data || {}, { preserveUser: true }),
0,
(err) => setCardStatus(String(err || 'Civitai: ошибка запроса')),
);
}
async function addRefFromUrl(url) {
if (!url) {
return;
}
addRefSlot({ src: url, select: false });
}
function readCardDraft() {
const fromForm = syncCardJsonFromForm();
if ($('sa_card_show_json')?.checked) {
const raw = $('sa_card_json')?.value || '';
try {
return JSON.parse(raw);
} catch (e) {
setCardStatus('Невалидный JSON');
return null;
}
}
return fromForm;
}
function saveCurrentCard({ enqueue } = {}) {
const sel = state.cardsSelection;
if (!sel) {
setCardStatus('Выбери модель');
return;
}
const card = readCardDraft();
if (!card) {
return;
}
card.kind = card.kind || sel.kind;
card.name = card.name || sel.name;
setCardStatus('Сохраняю…');
genericRequest(
'AssistentSaveCard',
{ kind: sel.kind, name: sel.name, card, enqueue_wanted: !!enqueue },
(data) => {
if (data.error) {
setCardStatus(data.error);
return;
}
state.modelCards[`${sel.kind}:${sel.name}`] = card;
setCardStatus(data.installed
? `Карточка Assistent сохранена · ${data.path}`
: `Черновик + wanted · ${data.path}`);
refreshInventory(() => renderCardsList());
if (enqueue || !data.installed) {
refreshWantedQueue();
}
},
0,
(err) => setCardStatus(String(err || 'Ошибка сохранения')),
);
}
function enqueueWantedOnly() {
const sel = state.cardsSelection;
const card = readCardDraft() || {};
if (!sel && !card.civitai_url) {
setCardStatus('Нужна модель или civitai_url');
return;
}
genericRequest(
'AssistentEnqueueWanted',
{
kind: (card.kind || sel?.kind || 'lora'),
url: card.civitai_url || '',
version_id: card.version_id || 0,
title: card.name || sel?.name || '',
card,
},
(data) => {
setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
refreshWantedQueue();
},
0,
(err) => setCardStatus(String(err || 'Ошибка enqueue')),
);
}
function shortLoraName(name) {
const s = String(name || '');
const base = s.split(/[/\\]/).pop() || s;
return base.replace(/\.safetensors$/i, '').slice(0, 28);
}
function renderLoraChips() {
const root = $('sa_lora_chips');
if (!root) {
return;
}
root.innerHTML = '';
let selected = [];
try {
if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
selected = loraHelper.selected.map((l) => ({
name: l.name || l,
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
}));
}
} catch (e) { /* ignore */ }
for (const l of selected) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sa-lora-chip';
btn.title = `${l.name} ×${l.weight} — клик снять`;
btn.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
btn.addEventListener('click', () => {
try {
if (typeof loraHelper !== 'undefined' && typeof loraHelper.removeLora === 'function') {
loraHelper.removeLora(l.name);
} else if (loraHelper?.selected) {
loraHelper.selected = loraHelper.selected.filter((x) => (x.name || x) !== l.name);
if (typeof loraHelper.rebuildUI === 'function') {
loraHelper.rebuildUI();
}
}
} catch (e) { /* ignore */ }
renderLoraChips();
});
root.appendChild(btn);
}
const add = document.createElement('button');
add.type = 'button';
add.className = 'sa-lora-chip sa-lora-add';
add.textContent = '+ LoRA';
add.title = 'Добавить из inventory';
add.addEventListener('click', (e) => {
e.stopPropagation();
openLoraPicker(add);
});
root.appendChild(add);
}
function openLoraPicker(anchor) {
document.querySelectorAll('.sa-lora-picker').forEach((n) => n.remove());
const picker = document.createElement('div');
picker.className = 'sa-lora-picker';
const inv = (state.inventory?.loras || []).slice().sort((a, b) => (b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0));
const filter = document.createElement('input');
filter.type = 'search';
filter.placeholder = 'Фильтр LoRA…';
filter.style.cssText = 'width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem;';
picker.appendChild(filter);
const list = document.createElement('div');
picker.appendChild(list);
const draw = () => {
list.innerHTML = '';
const q = filter.value.trim().toLowerCase();
let n = 0;
for (const l of inv) {
const name = l.name || '';
if (q && !String(name).toLowerCase().includes(q) && !String(l.title || '').toLowerCase().includes(q)) {
continue;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = `${shortLoraName(name)}${l.krea_likely ? ' · krea' : ''}`;
btn.title = name;
btn.addEventListener('click', async () => {
await applyPatch({
loras: [
...((() => {
try {
return (loraHelper?.selected || []).map((x) => ({
name: x.name || x,
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[x.name || x]) || 1,
}));
} catch (e) {
return [];
}
})()),
{ name, weight: l.default_weight ? parseFloat(l.default_weight) : 0.8, triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []) },
],
}, 'loras');
picker.remove();
renderLoraChips();
});
list.appendChild(btn);
if (++n >= 40) {
break;
}
}
if (!n) {
list.innerHTML = '<div class="sa-chat-empty-hint">Нет LoRA</div>';
}
};
filter.addEventListener('input', draw);
draw();
const composer = $('sa_composer') || document.body;
composer.style.position = composer.style.position || 'relative';
composer.appendChild(picker);
const onDoc = (ev) => {
if (!picker.contains(ev.target) && ev.target !== anchor) {
picker.remove();
document.removeEventListener('mousedown', onDoc);
}
};
setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
filter.focus();
}
function loadTasteFromServer() {
if (typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentGetTaste',
{},
(data) => {
const remote = data?.taste;
if (!remote || typeof remote !== 'object') {
return;
}
const remoteUpdated = remote.updated || 0;
const localUpdated = state.taste?.updated || 0;
// sqlite taste is the source of truth; localStorage only wins when it is strictly newer.
const localEmpty = !localUpdated
&& !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
if (localEmpty || remoteUpdated >= localUpdated) {
state.taste = {
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
avoid: Array.isArray(remote.avoid) ? remote.avoid.slice(0, 12) : [],
notes: String(remote.notes || '').slice(0, 400),
updated: remoteUpdated || Date.now(),
};
saveTasteLocalOnly();
}
},
0,
() => {},
);
}
function saveTasteLocalOnly() {
try {
localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
} catch (e) { /* ignore */ }
}
function saveTasteToServerDebounced() {
if (state.tasteSaveTimer) {
clearTimeout(state.tasteSaveTimer);
}
state.tasteSaveTimer = setTimeout(() => {
if (typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentSaveTaste',
{ taste: state.taste || {} },
() => {},
0,
() => {},
);
}, 800);
}
async function generateCardWithAssistent() {
const sel = state.cardsSelection;
if (!sel) {
setCardStatus('Выбери модель');
return;
}
if (state.busy) {
setCardStatus('Чат занят');
return;
}
setPackValue('catalog_card', { flash: true });
setView('chat');
const meta = await new Promise((resolve) => {
genericRequest(
'AssistentGetCardMeta',
{ kind: sel.kind, name: sel.name },
(data) => resolve(data),
0,
() => resolve(null),
);
});
const forced = `Write a recommendation card for this ${sel.kind}: ${sel.name}. Use metadata/triggers only; output one JSON card.`;
await sendChat({
forcedUserText: forced,
skipSlash: true,
skipAutoPack: true,
fromCards: true,
cardTarget: {
kind: sel.kind,
name: sel.name,
meta,
},
});
}
function inventoryIsStale(maxAgeMs = 20000) {
if (!state.inventoryFetchedAt) {
return true;
}
return (Date.now() - state.inventoryFetchedAt) > maxAgeMs;
}
async function ensureFreshInventory({ forceRescan } = {}) {
const rescan = forceRescan || inventoryIsStale(20000);
await refreshInventoryAsync({ rescan });
}
function triggerSwarmModelRefresh(done) {
if (typeof genericRequest !== 'function') {
if (done) {
done();
}
return;
}
genericRequest(
'TriggerRefresh',
{ strong: true },
() => {
if (done) {
done();
}
},
0,
() => {
if (done) {
done();
}
},
);
}
async function handleReplySideEffects(reply, civitaiResults, opts = {}) {
const { fromAutoCritique, fromVisionHop, fromCards, fromDebug } = opts;
if (fromCards) {
const card = extractCardJson(reply);
if (card) {
if ($('sa_card_json')) {
$('sa_card_json').value = JSON.stringify(card, null, 2);
}
if (opts.fromDownload || opts.cardTarget) {
const kind = card.kind || opts.cardTarget?.kind || 'lora';
const name = card.name || opts.cardTarget?.name;
if (name && typeof genericRequest === 'function') {
genericRequest(
'AssistentSaveCard',
{ kind, name, card, enqueue_wanted: false },
(data) => {
if (data?.path) {
state.modelCards[`${kind}:${name}`] = card;
setCardStatus(data.installed ? `Card saved → ${data.path}` : `Card draft → ${data.path}`);
setStatus(`Card saved for ${name}`);
}
},
0,
() => setCardStatus('Card draft ready — Save manually'),
);
}
} else {
setView('cards');
setCardStatus('Draft from Assistent — review & Save');
}
}
return;
}
const { patch } = extractPatch(reply);
let effective = patch;
if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug) {
const aspect = parseAspectFromUserText(opts.userText || '');
if (aspect && (replyMissingJsonPatch(reply) || isSameButAspectRequest(opts.userText || ''))) {
effective = { aspect, actions: ['generate'] };
if (state.lastPatch?.prompt) {
effective.prompt = state.lastPatch.prompt;
}
appendSystemNote(`Патч пустой — применил aspect ${aspect} сам.`);
}
}
if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug) {
const synthesized = synthesizePatchAfterEmptyFence(reply, opts.userText || '', opts);
if (synthesized) {
effective = synthesized;
appendSystemNote('Патч пустой — собрал prompt из ответа и запустил Generate.');
}
}
if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug
&& !opts.fromEmptyPatchRetry
&& replyMissingJsonPatch(reply)
&& (opts.userWantsGenerate || state.pendingSilentGen || userAsksContinue(opts.userText || ''))) {
appendSystemNote('Патч пустой — прошу модель дописать JSON.');
await sendChat({
skipSlash: true,
skipAutoPack: true,
fromEmptyPatchRetry: true,
userWantsGenerate: true,
forcedUserText:
'Ты написал «JSON Patch» без fenced ```json```. '
+ 'Сейчас ответь ТОЛЬКО одним fenced JSON объектом: '
+ '{"prompt":"<English Krea prompt for Qwen3-VL>","actions":["generate"]}. '
+ 'prompt — только английский (skill prompting). Без прозы, без ### заголовков.',
});
return;
}
if (opts.fromPromptEnRetry && effective) {
effective = mergePromptEnRewrite(effective);
}
if (effective) {
rememberLastPatch(effective);
}
if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
doInterruptNow();
}
if (civitaiResults && civitaiResults.length && $('sa_auto_download')?.checked) {
const pick = civitaiResults.find((r) => !r.already_installed && r.download_url && r.krea_likely)
|| civitaiResults.find((r) => !r.already_installed && r.download_url);
if (pick) {
downloadCivitaiLoRA(pick, null);
}
}
const suppressGen = !fromVisionHop && !fromAutoCritique && userAsksNoGenerate(opts.userText || '');
if (suppressGen && effective) {
effective = stripLookAt(stripGenerateAction(effective));
state.pendingSilentGen = false;
if (effective) {
rememberLastPatch(effective);
}
}
if (effective && !fromVisionHop && !fromAutoCritique && !suppressGen) {
const hopped = await maybeVisionHop(effective, opts.attachedSlotIds || []);
if (hopped) {
return;
}
}
if (fromDebug) {
// Q&A only — never apply patches / generate from a debug explanation turn.
state.pendingSilentGen = false;
return;
}
const wantsGen = !suppressGen && !!(opts.userWantsGenerate || state.pendingSilentGen
|| (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate')));
const willGen = !!(effective && !fromAutoCritique && !suppressGen
&& (wantsGen || $('sa_auto_generate')?.checked));
// Maximize chat-model prep: structure + EN for Krea before Swarm Generate runs.
if (willGen && effective?.prompt && promptNeedsKreaPrep(effective.prompt)
&& !opts.fromPromptEnRetry && !fromVisionHop && !fromDebug) {
state.pendingPromptEnMerge = { ...effective };
appendSystemNote('Готовлю промпт для Krea 2 чат-моделью (EN + структура)…');
setBusyPhase('refining');
await sendChat({
skipSlash: true,
skipAutoPack: true,
fromPromptEnRetry: true,
userWantsGenerate: true,
forcedUserText: buildKreaPromptPrepRequest(effective),
});
return;
}
const doApply = !!(effective && (wantsGen || $('sa_auto_apply')?.checked));
if (doApply) {
if (wantsGen) {
startBusyUi('silent_gen');
} else {
setBusyPhase('applying');
}
await applyPatch(effective, 'all');
syncLiveParamsBar();
updateTasteFromPatch(effective, opts.userText || '');
// Auto-Generate must not fire on «запомни / шаблон» turns — even if the model
// echoed a prompt patch or sneaked actions:["generate"].
if (!fromAutoCritique && !suppressGen && (wantsGen || $('sa_auto_generate')?.checked)) {
if (effective?.prompt && promptNeedsKreaPrep(effective.prompt)) {
setStatus('Промпт всё ещё не EN/Krea-ready — Generate с тем что есть');
}
const src = await runGenerateFromPatch(
{ ...effective, actions: Array.isArray(effective.actions) ? effective.actions : ['generate'] },
{ force: wantsGen },
);
if (src) {
await maybeAutoCritique(src);
await maybeAutoVisionLook(src);
}
} else if (!state.generating) {
stopBusyUi(suppressGen ? 'Запомнил · без Generate' : (wantsGen ? 'Применено' : ''));
}
} else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) {
setStatus('Ответ без JSON-патча — ничего не применено');
}
state.pendingSilentGen = false;
}
async function applyQuickPatch(patch, note) {
const withActions = { ...patch };
if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) {
withActions.actions = ['generate'];
}
const prevIntent = state.lastUserParamIntent;
state.lastUserParamIntent = true;
await applyPatch(withActions, 'all');
state.lastUserParamIntent = prevIntent;
setStatus(note || 'Applied');
if ($('sa_auto_generate')?.checked) {
await runGenerateFromPatch(withActions);
}
syncChipHighlight();
}
function syncChipHighlight() {
const bar = $('sa_chips');
if (!bar) {
return;
}
const cur = guessAspectFromSize(val('input_width'), val('input_height'));
const seed = val('input_seed');
bar.querySelectorAll('[data-aspect]').forEach((btn) => {
btn.classList.toggle('sa-chip-active', btn.getAttribute('data-aspect') === cur);
});
bar.querySelectorAll('[data-seed]').forEach((btn) => {
const mode = btn.getAttribute('data-seed');
const active = (mode === 'lock' && seed && seed !== '-1') || (mode === 'random' && (!seed || seed === '-1'));
btn.classList.toggle('sa-chip-active', active);
});
}
function appendSystemNote(text) {
const box = $('sa_messages');
if (!box) {
return;
}
hideChatEmpty();
const div = document.createElement('div');
div.className = 'sa-msg assistant sa-system-note';
div.textContent = text;
box.appendChild(div);
scrollMessagesToBottom();
}
function clipDebug(s, max) {
const t = String(s || '').replace(/\s+/g, ' ').trim();
if (!t) {
return '—';
}
return t.length > max ? `${t.slice(0, max)}…` : t;
}
function formatDebugLoras(list) {
if (!Array.isArray(list) || !list.length) {
return 'нет';
}
return list.slice(0, 8).map((l) => {
const name = l?.name || l;
const w = l?.weight != null ? `@${l.weight}` : '';
return `${name}${w}`;
}).join(', ');
}
function buildDebugSummary() {
const persona = $('sa_persona')?.value || 'neutral';
const pack = $('sa_pack')?.value || defaultPackId();
const chatModel = $('sa_model')?.value || '—';
const embed = $('sa_embed_model')?.value || state.preferredEmbed || '—';
const profile = detectKreaProfileName();
const defaults = mergedGenerationDefaults(profile);
const session = state.sessionExact || {};
const exactGen = state.exact?.generation || state.config?.exact?.generation || {};
const ctx = (() => {
try {
return collectLiveContext();
} catch (e) {
return {};
}
})();
const aspect = guessAspectFromSize(ctx.width, ctx.height) || defaults.aspect || '—';
const why = [];
if (Object.keys(session).length) {
why.push(`session_exact перекрывает Exact: ${Object.keys(session).join(', ')}`);
} else {
why.push('session_exact пуст — params из Exact + профиль чекпоинта');
}
why.push(`профиль чекпоинта: ${profile} (имя/title → turbo|raw)`);
if (persona === 'cinema' || state.exact?.generation?.aspect) {
why.push(`persona/exact aspect: ${state.exact?.generation?.aspect || exactGen.aspect || '—'}`);
}
if (state.lastPatch) {
const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null && k !== 'notes');
why.push(`последний патч задал: ${keys.slice(0, 12).join(', ')}`);
} else {
why.push('последнего патча Assistent ещё нет');
}
why.push('приоритет: user → About the user → session_exact → exact(+persona) → live UI → craft memory_hits');
const lines = [
'### Debug Assistent',
`persona=${persona} · pack=${pack}`,
`chat=${chatModel} · embed=${embed}`,
`skills=${(state.enabledSkills || []).join(',') || '—'}`,
`auto: apply=${!!$('sa_auto_apply')?.checked} gen=${!!$('sa_auto_generate')?.checked} vision=${!!$('sa_auto_vision')?.checked} critique=${!!$('sa_auto_critique')?.checked}`,
'',
'Live SwarmUI:',
` ckpt=${ctx.checkpoint?.name || '—'} · krea_profile=${ctx.krea_profile || profile}`,
` ${ctx.width || '?'}×${ctx.height || '?'} (${aspect}) · steps=${ctx.steps ?? '—'} · cfg=${ctx.cfg ?? '—'} · sigma=${ctx.sigma_shift ?? '—'} · seed=${ctx.seed ?? '—'} · batch=${ctx.batch ?? '—'}`,
` loras: ${formatDebugLoras(ctx.selected_loras || ctx.enabled_loras)}`,
` available_loras=${(ctx.available_loras || []).length}${ctx.available_loras_truncated ? ` truncated/${ctx.available_loras_total || '?'}` : ''}`,
` prompt: ${clipDebug(ctx.prompt, 220)}`,
` negative: ${clipDebug(ctx.negative, 120)}`,
` init=${!!ctx.has_init_image} mask=${!!ctx.has_mask_image} prompt_images=${ctx.prompt_image_count || 0}`,
` has_vision_image=${!!ctx.has_vision_image} · images_in_request=${!!ctx.images_in_request} · vision_ready=${visionReadySlots().length}`,
` context_json_chars≈${JSON.stringify(ctx).length} · last_system_chars=${state.lastSystemChars || '—'} · last_context_chars=${state.lastContextChars || '—'}`,
state.lastSystemLayers
? ` system_layers: ${Object.entries(state.lastSystemLayers).map(([k, v]) => `${k}=${v}`).join(' · ')}`
: ' system_layers: — (отправь сообщение, чтобы заполнить)',
'',
'Exact defaults (merged):',
` generation=${JSON.stringify(exactGen)}`,
` effective=${JSON.stringify({
steps: defaults.steps,
cfg: defaults.cfg,
sigma_shift: defaults.sigma_shift,
aspect: defaults.aspect,
images: defaults.images,
profile: defaults.profile,
})}`,
` session_exact=${Object.keys(session).length ? JSON.stringify(session) : '{}'}`,
'',
'Почему так:',
...why.map((w) => ` · ${w}`),
];
if (state.lastPatch) {
lines.push('', `last_patch: ${clipDebug(JSON.stringify(state.lastPatch), 360)}`);
}
return lines.join('\n');
}
async function handleSlashCommand(raw) {
const text = String(raw || '').trim();
if (!text.startsWith('/')) {
return false;
}
const parts = text.slice(1).split(/\s+/);
const cmd = (parts[0] || '').toLowerCase();
const arg = parts.slice(1).join(' ').trim();
if (cmd === 'help' || cmd === '?') {
appendSystemNote(HELP_TEXT);
setStatus('/help');
return true;
}
if (cmd === 'new' || cmd === 'newchat') {
await startNewChat({ saveCurrent: true });
return true;
}
if (cmd === 'history' || cmd === 'chats' || cmd === 'sessions') {
setChatsPanelOpen(true);
setStatus('/history');
return true;
}
if (cmd === 'debug' || cmd === 'dbg' || cmd === 'why') {
const dump = buildDebugSummary();
appendSystemNote(dump);
const argL = String(arg || '').toLowerCase().trim();
const wantLlm = cmd === 'why'
|| /^(ask|llm|explain|поясни|почему|модель)(\s|$)/i.test(argL);
if (wantLlm) {
setStatus('/debug ask…');
await sendChat({
forcedUserText:
'Отладка Assistent. Ниже факты UI (уже собраны клиентом). ' +
'Кратко своими словами (5–10 строк, язык пользователя): какие промпт/params сейчас, ' +
'что из Exact vs session_exact vs live, что сделал последний патч и почему так логично. ' +
'Без JSON patch, без generate, без look_at.\n\n' + dump,
skipSlash: true,
skipAutoPack: true,
fromDebug: true,
});
} else {
setStatus('/debug');
}
return true;
}
if (cmd === 'gen' || cmd === 'generate') {
const prev = findCurrentGenerateSrc();
startBusyUi('generating');
state.generating = true;
setInterruptVisible(true);
if (!triggerGenerate()) {
state.generating = false;
stopBusyUi('Could not start Generate');
return true;
}
const src = await waitForNewImage(prev);
state.generating = false;
setInterruptVisible(state.busy);
if (src) {
const gen = generateSlot();
if (gen) {
gen.src = src;
renderBoard();
}
stopBusyUi('Generate done');
} else {
stopBusyUi('Generate finished');
}
return true;
}
if (cmd === 'look') {
const id = normalizeSlotId(arg || GEN_ID) || GEN_ID;
const slot = slotById(id);
if (!slot) {
setStatus(`Неизвестный слот: ${arg || GEN_ID}`);
return true;
}
if (slot.type !== 'generate') {
setBoardTab('refs');
} else {
setBoardTab('generate');
}
if (!slot.src && id === GEN_ID) {
const src = findCurrentGenerateSrc();
if (src) {
slot.src = src;
}
}
if (!slot.src) {
setStatus(`Slot ${id} is empty`);
return true;
}
slot.attach = true;
renderBoard();
if ($('sa_input')) {
$('sa_input').value = `Look at ${id} and describe what you see.`;
}
setPackValue('critique_image', { flash: true });
await sendChat({ forceSlotIds: [id], skipAutoPack: true });
return true;
}
if (cmd === 'init') {
const src = selectedSrc() || findCurrentGenerateSrc();
if (!src) {
setStatus('No image for Init');
return true;
}
await setInitFromSrc(src);
setPackValue('inpaint_edit', { flash: true });
return true;
}
if (cmd === 'mask') {
const src = selectedSrc();
if (!src) {
setStatus('Select a window with a mask image');
return true;
}
await setMaskFromSrc(src);
setPackValue('inpaint_edit', { flash: true });
return true;
}
if (cmd === 'clear') {
clearInitAndMask();
return true;
}
if (cmd === 'interrupt' || cmd === 'stop') {
doInterruptNow();
clearInFlightUi({ status: 'Прервано' });
return true;
}
if (cmd === 'aspect') {
const key = normalizeAspect(arg);
if (!key) {
setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`);
return true;
}
await applyQuickPatch({ aspect: key, actions: ['generate'] }, `Aspect ${key}`);
return true;
}
if (cmd === 'seed') {
const mode = (arg || 'random').toLowerCase();
if (mode === 'lock' || mode === 'keep') {
await applyQuickPatch({ lock_seed: true }, 'Seed locked');
} else {
await applyQuickPatch({ seed: -1, vary: true, actions: ['generate'] }, 'Seed random');
}
return true;
}
if (cmd === 'vary') {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)');
return true;
}
if (cmd === 'inventory' || cmd === 'inv') {
setStatus('Rescanning models…');
triggerSwarmModelRefresh(async () => {
await refreshInventoryAsync({ rescan: true });
const n = state.inventory?.loras?.length || 0;
const ck = state.inventory?.checkpoints?.length || 0;
appendSystemNote(`Inventory refreshed: ${n} LoRAs, ${ck} checkpoints.`);
setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts (rescanned)`);
});
return true;
}
if (cmd === 'pack') {
if (!setPackValue(arg, { flash: true, user: true })) {
setStatus('Pack: write|critique|compose|params|inpaint|describe');
} else {
setStatus(`Pack → ${$('sa_pack')?.value}`);
}
return true;
}
if (cmd === 'остынь' || cmd === 'ostyn' || cmd === 'cool' || cmd === 'cooldown') {
coolDownHorny();
return true;
}
if (cmd === 'horny-game' || cmd === 'hornygame' || cmd === 'horny_game') {
await startHornyGame();
return true;
}
if (cmd === 'civitai') {
if (!arg) {
setStatus('/civitai <query>');
return true;
}
if ($('sa_input')) {
$('sa_input').value = `Find a Krea 2 LoRA for: ${arg}`;
}
setPackValue(defaultPackId(), { flash: true });
await sendChat({
skipAutoPack: true,
forcedUserText: `Search Civitai for Krea-compatible LoRA: ${arg}. Prefer actions search_civitai.`,
});
return true;
}
if (cmd === 'persona') {
const sub = (parts[1] || 'new').toLowerCase();
const rest = parts.slice(2).join(' ').trim();
setPackValue('author_persona', { flash: true, user: true });
if (sub === 'save') {
await sendChat({
skipAutoPack: true,
forcedUserText:
'Сохрани согласованный черновик личности сейчас (persona_clone / persona_write). Не удаляй личности.',
});
return true;
}
const fromId = sub === 'clone' && rest
? rest.split(/\s+/)[0]
: ($('sa_persona')?.value || 'neutral');
await sendChat({
skipAutoPack: true,
forcedUserText:
`Начни интервью author_persona: клон с источника «${fromId}». ` +
'Спрашивай по полкам группами. Не пиши на диск, пока мало ответов. Не удаляй личности.',
});
return true;
}
appendSystemNote(`Unknown command /${cmd}.\n\n${HELP_TEXT}`);
setStatus(`Unknown /${cmd}`);
return true;
}
async function maybeVisionHop(patch, attachedSlotIds) {
const ids = lookAtIdsFromPatch(patch);
if (!ids.length || state.visionHopUsed) {
return false;
}
scrubPreviewFromGenerateSlot();
const have = ids.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
if (!have.length) {
const genSrc = findCurrentGenerateSrc();
if (ids.includes(GEN_ID) && genSrc) {
const gen = generateSlot();
if (gen) {
gen.src = genSrc;
have.push(gen);
}
}
}
if (!have.length) {
setStatus('look_at: нет реального кадра (превью модели пропущено)');
return false;
}
const already = new Set(attachedSlotIds || []);
const need = have.filter((s) => !already.has(s.id));
if (!need.length) {
return false;
}
state.visionHopUsed = true;
for (const s of need) {
s.attach = true;
}
renderBoard();
if ($('sa_input')) {
$('sa_input').value = `Look at board slots: ${need.map((s) => s.id).join(', ')}. Continue using these images.`;
}
setStatus(`Vision hop ← ${need.map((s) => s.label).join(', ')}`);
await sendChat({ fromVisionHop: true, forceSlotIds: need.map((s) => s.id) });
return true;
}
async function sendChat(opts = {}) {
if ((state.busy || state.generating) && !opts.fromVisionHop && !opts.fromAutoCritique) {
return;
}
const rawInput = ($('sa_input')?.value || '').trim();
const text = (opts.forcedUserText || rawInput).trim();
if (!text) {
return;
}
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = userAsksGenerate(text) || userAsksContinue(text) || isSameButAspectRequest(text);
if (userAsksNoGenerate(text)) {
state.pendingSilentGen = false;
}
}
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) {
if (rawInput.startsWith('/')) {
if ($('sa_input')) {
$('sa_input').value = '';
}
const handled = await handleSlashCommand(rawInput);
if (handled) {
return;
}
}
}
// «такую же, только 9 на 16» — apply aspect + Generate without waiting for an empty LLM critique.
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug
&& !opts.fromCards && isSameButAspectRequest(text)) {
const aspect = parseAspectFromUserText(text);
if (aspect) {
if ($('sa_input')) {
$('sa_input').value = '';
}
appendMessage('user', text);
state.history.push({ role: 'user', content: text });
persistHistory();
restoreDefaultPackAfterHop();
const patch = { aspect, actions: ['generate'] };
if (state.lastPatch?.prompt) {
patch.prompt = state.lastPatch.prompt;
}
if (Array.isArray(state.lastPatch?.loras) && state.lastPatch.loras.length) {
patch.loras = state.lastPatch.loras;
}
appendSystemNote(`Ставлю ${aspect} и Generate (тот же промпт) — без повторной критики.`);
await applyQuickPatch(patch, `Aspect ${aspect}`);
return;
}
}
if (!updateGate()) {
setStatus('Выбери модель Krea 2');
return;
}
if (!opts.skipAutoPack && !opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromCards) {
const guessed = autoSelectPack(text);
if (guessed) {
setPackValue(guessed, { flash: true });
}
}
// Cards mode must not be overridden by auto-pack; keep catalog_card.
if (opts.fromCards || state.view === 'cards') {
setPackValue('catalog_card', { flash: false });
}
const pack = $('sa_pack')?.value || defaultPackId();
const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
const model = $('sa_model')?.value;
if (!model) {
setStatus('Выбери модель Ollama в ⚙');
refreshModels();
return;
}
const chatEpoch = bumpChatEpoch();
state.busy = true;
// 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();
// Always pull latest LoRA/checkpoint list before the LLM sees context
// (rescans disk when inventory is older than ~20s or after downloads).
setStatus('Обновляю inventory…');
try {
await ensureFreshInventory({ forceRescan: !!opts.fromDownload });
await prefetchActiveModelCards();
} catch (e) {
console.warn('Assistent inventory refresh', e);
}
if (chatEpoch !== state.chatEpoch) {
return;
}
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop) {
state.critiqueHopUsed = false;
state.visionHopUsed = false;
}
let wantedIds = (opts.forceSlotIds || []).map(normalizeSlotId).filter(Boolean);
// JPEG only on look_at / vision hop — never auto-attach every board ref to ordinary chat.
const sendVision = !!(opts.fromVisionHop || (wantedIds.length && opts.forceSlotIds));
if (!sendVision) {
wantedIds = [];
}
const visionSlots = wantedIds.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
let images = null;
if (visionSlots.length) {
startBusyUi('encoding');
setStatus('Кодирую изображение…');
images = [];
for (const slot of visionSlots) {
if (chatEpoch !== state.chatEpoch) {
return;
}
const b64 = await imageToBase64ForOllama(slot.src);
if (b64) {
images.push(b64);
}
}
if (!images.length) {
images = null;
}
}
if (chatEpoch !== state.chatEpoch) {
return;
}
const msgMeta = {
persona: currentPersonaInfo(),
pack,
silentPatch: !!state.pendingSilentGen,
};
state.history.push({ role: 'user', content: text });
if (state.pendingPersonaNote) {
state.history.push({ role: 'user', content: state.pendingPersonaNote, systemish: true });
state.pendingPersonaNote = null;
}
appendMessage('user', text);
$('sa_input').value = '';
persistHistory();
const context = collectLiveContext();
// has_vision_image = board has a real frame (even when JPEG is not in this request).
// images_in_request = JPEG bytes are attached to the last user message this turn.
context.has_vision_image = visionReadySlots().length > 0;
context.images_in_request = !!(images && images.length);
context.attached_slot_ids = attachableSlots().map((s) => s.id);
context.vision_slot_ids = visionSlots.map((s) => s.id);
context.persona = persona;
if (opts.cardTarget) {
context.card_target = opts.cardTarget;
}
if (opts.fromCards || pack === 'catalog_card') {
context.auto_apply = false;
context.auto_generate = false;
}
await prefetchActiveModelCards();
if (chatEpoch !== state.chatEpoch) {
return;
}
// Refresh cards into context after prefetch
const refreshed = collectLiveContext();
context.model_cards = refreshed.model_cards;
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;
}
startBusyUi('thinking');
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
// Flat fields: SwarmUI JObject params receive the whole request body.
const payload = {
baseUrl,
model,
pack,
persona,
includeBase: true,
messages,
context_json: JSON.stringify(context),
skills: state.enabledSkills || [],
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
};
const finishOk = async (reply, civitaiResults, meta = {}) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
if (meta.system_chars != null) {
state.lastSystemChars = Number(meta.system_chars) || 0;
}
if (meta.system_layers && typeof meta.system_layers === 'object') {
state.lastSystemLayers = meta.system_layers;
}
try {
state.lastContextChars = (context && JSON.stringify(context).length) || 0;
} catch (e) {
state.lastContextChars = 0;
}
const prose = extractPatch(reply).prose || reply;
state.history.push({ role: 'assistant', content: prose, persona, pack });
persistHistory();
setBusyPhase(state.pendingSilentGen || userAsksGenerate(text) || userAsksContinue(text) ? 'silent_gen' : 'thinking');
try {
await handleReplySideEffects(reply, civitaiResults, {
...opts,
userText: text,
userWantsGenerate: !!state.pendingSilentGen || userAsksGenerate(text) || userAsksContinue(text),
attachedSlotIds: visionSlots.map((s) => s.id),
});
} finally {
if (chatEpoch !== state.chatEpoch) {
return;
}
// 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);
}
}
};
const finishErr = (msg) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
state.busy = false;
setInterruptVisible(state.generating);
stopBusyUi(msg);
if (state.streamEl) {
state.streamEl.classList.remove('sa-streaming', 'sa-typing');
state.streamEl.classList.add('error');
setAssistantBody(state.streamEl, msg);
state.streamEl = null;
state.streamMeta = null;
} else {
appendMessage('error', msg);
}
};
if (typeof makeWSRequest === 'function') {
beginStreamMessage(msgMeta);
makeWSRequest(
'AssistentChatWS',
payload,
(data) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
if (data.phase === 'waiting_ollama') {
// 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 = state.expectColdLoad
? `Загружаю ${modelShort(model)} в GPU…`
: 'Думаю…';
}
return;
}
if (data.error) {
finishErr(String(data.error));
return;
}
if (data.clear_stream) {
if (state.streamEl) {
state.streamEl.classList.add('sa-typing');
const body = state.streamEl.querySelector('.sa-msg-body') || state.streamEl;
body.innerHTML = '<span class="sa-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="sa-typing-label">Уточняю…</span>';
}
setBusyPhase('refining');
return;
}
if (data.delta) {
appendStreamDelta(data.delta);
return;
}
if (data.done || data.reply != null) {
const reply = data.reply || (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || '';
const civitai = data.civitai_results || [];
finalizeStreamMessage(reply, civitai);
finishOk(reply, civitai, { system_chars: data.system_chars, system_layers: data.system_layers });
}
},
0,
(err) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
// Fallback to HTTP AssistentChat
console.warn('AssistentChatWS failed, falling back', err);
if (state.streamEl) {
state.streamEl.remove();
state.streamEl = null;
state.streamMeta = null;
}
genericRequest(
'AssistentChat',
payload,
(data) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
if (data.error) {
finishErr(String(data.error));
return;
}
const reply = data.reply || '';
appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
},
0,
(err2) => finishErr(String(err2 || err || 'Chat failed')),
);
},
);
return;
}
genericRequest(
'AssistentChat',
payload,
(data) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
if (data.error) {
finishErr(String(data.error));
return;
}
const reply = data.reply || '';
appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
},
0,
(err) => finishErr(String(err || 'Chat failed')),
);
}
function wireDropZone() {
const board = $('sa_board');
const layout = $('sa_layout');
layout?.addEventListener('dragover', (e) => {
if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/uri-list')) {
e.preventDefault();
}
});
layout?.addEventListener('drop', async (e) => {
if (!e.dataTransfer) {
return;
}
if (e.target && e.target.closest && e.target.closest('.sa-slot, .sa-add-cell')) {
return;
}
e.preventDefault();
await handleDropDataTransfer(e.dataTransfer);
});
board?.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.lightboxIndex >= 0) {
e.preventDefault();
closeGenLightbox();
return;
}
if ((e.key === 'Enter' || e.key === ' ') && state.boardTab === 'generate' && state.selectedGenResultId) {
const row = (state.genResults || []).find((r) => r.id === state.selectedGenResultId);
if (row?.src) {
e.preventDefault();
openGenLightbox(row.id);
return;
}
}
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.lightboxIndex >= 0) {
e.preventDefault();
stepGenLightbox(e.key === 'ArrowRight' ? 1 : -1);
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
return;
}
e.preventDefault();
clearSlot(state.selectedSlotId);
}
});
document.addEventListener('paste', async (e) => {
const pane = document.getElementById('assistent');
if (!pane || !pane.classList.contains('active')) {
return;
}
if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
const items = e.clipboardData?.items;
let hasImage = false;
if (items) {
for (const item of items) {
if (item.type.startsWith('image/')) {
hasImage = true;
break;
}
}
}
if (!hasImage) {
return;
}
}
const items = e.clipboardData?.items;
if (!items) {
return;
}
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
const sel = selectedSlot();
await acceptImageFile(file, sel && sel.type === 'ref' ? sel.id : null);
}
return;
}
}
});
}
function wireSplitter() {
const splitter = $('sa_splitter');
const layout = $('sa_layout');
const pane = $('sa_image_pane');
if (!splitter || !layout || !pane) {
return;
}
let dragging = false;
splitter.addEventListener('mousedown', (e) => {
e.preventDefault();
dragging = true;
splitter.classList.add('sa-dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!dragging) {
return;
}
const rect = layout.getBoundingClientRect();
const x = e.clientX - rect.left;
const pct = Math.min(56, Math.max(22, (x / rect.width) * 100));
const value = `${pct}%`;
document.documentElement.style.setProperty('--sa-image-width', value);
localStorage.setItem(LS_PANE_WIDTH, value);
});
window.addEventListener('mouseup', () => {
if (!dragging) {
return;
}
dragging = false;
splitter.classList.remove('sa-dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
saveUiStateToDisk();
});
}
function registerSendButton() {
if (typeof registerMediaButton !== 'function') {
setTimeout(registerSendButton, 500);
return;
}
if (window.__swarmAssistentMediaRegistered) {
return;
}
window.__swarmAssistentMediaRegistered = true;
registerMediaButton(
'Send to Assistent',
(src) => {
putImageOnBoard(src, {
switchTab: true,
note: 'Image sent to Assistent',
preferSelected: false,
});
const pack = $('sa_pack');
if (pack && (pack.value === 'ordinary' || pack.value === 'write_prompt')) {
pack.value = 'critique_image';
saveSettings();
}
},
'Open Assistent with this image (vision / critique / prompt help)',
['image'],
true,
true,
);
}
/** Disk state first (chats + UI prefs), then config / models / inventory. */
async function bootstrapPersisted() {
try {
await applyDiskUiState();
} catch (e) {
console.warn('Assistent: ui-state restore failed', e);
}
try {
await initChatSessions();
} catch (e) {
console.warn('Assistent: chat sessions failed', e);
}
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
refreshModels();
refreshInventory(() => {
renderCardsList();
renderLoraChips();
});
});
probeOllamaHealth();
refreshWantedQueue();
}
function wire() {
if (!$('swarm_assistent_root')) {
return;
}
if (typeof genericRequest !== 'function') {
setTimeout(wire, 300);
return;
}
if (window.__swarmAssistentWired) {
return;
}
window.__swarmAssistentWired = true;
loadSettings();
loadTaste();
loadTasteFromServer();
setView(state.view || 'chat');
updateGate();
ensureBoard();
setBoardTab(state.boardTab || 'generate', { persist: false });
syncGenerateSlot();
if (wantsAutoVision()) {
refreshImagePreview();
}
bootstrapPersisted();
wireDropZone();
wireSplitter();
registerSendButton();
wireSlashInput();
wireCardForm();
$('sa_btn_new_chat')?.addEventListener('click', () => startNewChat({ saveCurrent: true }));
$('sa_btn_chats')?.addEventListener('click', (e) => {
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
});
$('sa_session_label')?.addEventListener('click', (e) => {
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
});
$('sa_session_label')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setChatsPanelOpen(!state.chatsPanelOpen);
}
});
$('sa_chats_panel')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_chats_list')?.addEventListener('click', (e) => {
const row = e.target.closest('.sa-chat-row');
if (!row) {
return;
}
const id = row.dataset.id;
if (e.target.closest('[data-del]')) {
e.preventDefault();
if (window.confirm('Удалить этот чат из истории?')) {
deleteChat(id);
}
return;
}
if (e.target.closest('[data-open]')) {
switchToChat(id);
}
});
let chatsSearchTimer = null;
$('sa_chats_search')?.addEventListener('input', () => {
const q = ($('sa_chats_search')?.value || '').trim();
state.chatsQuery = q;
if (!q) {
state.chatsSearchHits = null;
renderChatsList();
return;
}
renderChatsList();
clearTimeout(chatsSearchTimer);
chatsSearchTimer = setTimeout(async () => {
try {
const hits = await diskPersist()?.searchChats?.(q);
if ((state.chatsQuery || '') !== q) {
return;
}
state.chatsSearchHits = Array.isArray(hits) ? hits : [];
renderChatsList();
} catch (e) { /* ignore */ }
}, 220);
});
$('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);
$('sa_persona_delete')?.addEventListener('click', () => deleteCurrentOverlayPersona());
$('sa_cards_kind')?.addEventListener('change', renderCardsList);
$('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true }));
$('sa_btn_card_meta')?.addEventListener('click', () => fetchCardMetaLive());
$('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', () => {
if (state.view === 'settings') {
closeSettings();
} else {
openSettings(state.settingsTab || 'behavior');
}
});
$('sa_settings_close')?.addEventListener('click', () => closeSettings());
document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
});
$('sa_btn_mem_refresh')?.addEventListener('click', () => {
refreshMemoryList();
refreshWantedQueue();
});
$('sa_mem_kind')?.addEventListener('change', renderMemoryList);
$('sa_mem_scope')?.addEventListener('change', renderMemoryList);
$('sa_mem_search')?.addEventListener('input', () => renderMemoryList());
$('sa_btn_mem_clear_kind')?.addEventListener('click', () => {
const kind = memoryKindFilter();
clearCraftMemory({ kind: kind === 'all' ? '' : kind, label: kind === 'all' ? 'весь крафт (фильтр типа)' : `тип ${kind}` });
});
$('sa_btn_mem_clear_persona')?.addEventListener('click', () => {
clearCraftMemory({ scope: 'personal', persona: $('sa_persona')?.value || 'neutral', label: 'крафт этой личности' });
});
$('sa_btn_mem_clear_shared')?.addEventListener('click', () => {
clearCraftMemory({ scope: 'shared', label: 'общую крафт-память' });
});
$('sa_btn_mem_clear_all')?.addEventListener('click', () => {
clearCraftMemory({ label: 'весь крафт (non-bundled)' });
});
$('sa_btn_prefs_refresh')?.addEventListener('click', () => refreshUserPrefs());
$('sa_btn_pref_add_global')?.addEventListener('click', () => addUserPref('global'));
$('sa_btn_pref_add_persona')?.addEventListener('click', () => addUserPref('persona'));
$('sa_btn_prefs_clear_global')?.addEventListener('click', () => clearUserPrefs('global'));
$('sa_btn_prefs_clear_persona')?.addEventListener('click', () => clearUserPrefs('persona'));
$('sa_btn_prefs_clear_all')?.addEventListener('click', () => clearUserPrefs('all'));
$('sa_user_prefs_weight')?.addEventListener('input', () => {
const lab = $('sa_user_prefs_weight_val');
if (lab) {
lab.textContent = Number($('sa_user_prefs_weight').value).toFixed(1);
}
});
$('sa_user_prefs_weight')?.addEventListener('change', () => saveKnobs());
$('sa_memory_top_k')?.addEventListener('change', () => saveKnobs());
$('sa_btn_knobs_save')?.addEventListener('click', () => saveKnobs());
$('sa_btn_reset_ui')?.addEventListener('click', () => resetUiState());
$('sa_btn_persona_export')?.addEventListener('click', () => exportSelectedPersona());
$('sa_btn_persona_import')?.addEventListener('click', () => $('sa_persona_import_file')?.click());
$('sa_persona_import_file')?.addEventListener('change', (e) => {
const file = e.target?.files?.[0];
if (file) {
importPersonaFile(file);
}
e.target.value = '';
});
$('sa_btn_persona_clone')?.addEventListener('click', () => cloneSelectedPersona());
$('sa_btn_persona_delete_panel')?.addEventListener('click', () => deleteSelectedOverlayPersona());
$('sa_btn_settings_health')?.addEventListener('click', () => {
probeOllamaHealth();
setTimeout(syncSettingsHealthLine, 400);
});
$('sa_settings_chat_model')?.addEventListener('change', () => {
const v = $('sa_settings_chat_model')?.value;
if (v && $('sa_model')) {
$('sa_model').value = v;
saveSettings();
}
});
$('sa_btn_look_result')?.addEventListener('click', () => askLookAtResult());
$('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
document.addEventListener('keydown', (e) => {
if (state.lightboxIndex >= 0) {
if (e.key === 'Escape') {
e.preventDefault();
closeGenLightbox();
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
stepGenLightbox(-1);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
stepGenLightbox(1);
return;
}
}
if (e.key !== 'Escape') {
return;
}
let closed = false;
if (state.view === 'settings') {
closeSettings();
closed = true;
}
if (state.chatsPanelOpen) {
setChatsPanelOpen(false);
closed = true;
}
const slash = $('sa_slash_menu');
if (slash && !slash.hidden) {
slash.hidden = true;
closed = true;
}
closeAllMoreMenus();
if (closed) {
e.preventDefault();
}
});
// Focus composer when Assistent tab becomes visible
document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => {
setTimeout(() => $('sa_input')?.focus(), 80);
});
$('sa_btn_refresh_models')?.addEventListener('click', () => {
saveSettings();
refreshModels();
probeOllamaHealth();
});
$('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
renderCardsList();
renderLoraChips();
}, { rescan: true }));
$('sa_btn_add_ref')?.addEventListener('click', () => {
setBoardTab('refs');
addRefSlot({ select: true });
});
$('sa_btn_use_current')?.addEventListener('click', () => snapshotGenerateToRef());
$('sa_btn_as_init')?.addEventListener('click', async () => {
closeAllMoreMenus();
const src = selectedSrc() || findCurrentGenerateSrc();
if (!src) {
setStatus('Нет изображения для Init');
return;
}
await setInitFromSrc(src);
const pack = $('sa_pack');
if (pack && (pack.value === 'ordinary' || pack.value === 'write_prompt')) {
setPackValue('inpaint_edit', { flash: true });
}
});
$('sa_btn_as_mask')?.addEventListener('click', async () => {
closeAllMoreMenus();
const src = selectedSrc();
if (!src) {
setStatus('Выбери окно с маской');
return;
}
await setMaskFromSrc(src);
setPackValue('inpaint_edit', { flash: true });
});
$('sa_btn_clear_init')?.addEventListener('click', () => {
clearInitAndMask();
closeAllMoreMenus();
});
$('sa_btn_clear_image')?.addEventListener('click', () => clearSlot(state.selectedSlotId));
$('sa_btn_board_more')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleMoreMenu('sa_board_more_menu', 'sa_btn_board_more');
});
$('sa_btn_send')?.addEventListener('click', () => sendChat());
$('sa_btn_build_gen')?.addEventListener('click', () => buildCurrentAndGenerate());
$('sa_btn_interrupt')?.addEventListener('click', () => {
doInterruptNow();
clearInFlightUi({ status: 'Прервано' });
});
$('sa_btn_clear')?.addEventListener('click', () => {
if (window.confirm('Очистить весь чат Assistent?')) {
clearChatHistory();
}
});
$('sa_btn_clear_more')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleMoreMenu('sa_clear_more_menu', 'sa_btn_clear_more');
});
$('sa_btn_clear_confirm')?.addEventListener('click', () => {
closeAllMoreMenus();
if (window.confirm('Очистить весь чат Assistent?')) {
clearChatHistory();
}
});
$('sa_btn_clear_patches')?.addEventListener('click', () => {
closeAllMoreMenus();
clearPatchBlocksOnly();
});
$('sa_btn_card_to_chat')?.addEventListener('click', () => {
if (state.cardsSelection) {
sendCardToChat(state.cardsSelection);
} else {
setCardStatus('Сначала выбери модель в списке');
}
});
document.addEventListener('click', () => {
if (state.chatsPanelOpen) {
setChatsPanelOpen(false);
}
closeAllMoreMenus();
});
$('sa_board_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_clear_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_base_url')?.addEventListener('change', saveSettings);
$('sa_model')?.addEventListener('change', () => {
const v = $('sa_model')?.value;
if (v && $('sa_settings_chat_model')) {
$('sa_settings_chat_model').value = v;
}
saveSettings();
});
$('sa_embed_model')?.addEventListener('change', () => {
state.preferredEmbed = $('sa_embed_model')?.value || '';
saveSettings();
});
$('sa_pack')?.addEventListener('change', () => {
state.packUserTouched = true;
saveSettings();
syncModeBadge();
});
$('sa_chips')?.addEventListener('click', async (e) => {
const btn = e.target.closest('.sa-chip');
if (!btn || state.busy || state.generating) {
return;
}
const aspect = btn.getAttribute('data-aspect');
const seed = btn.getAttribute('data-seed');
const vary = btn.getAttribute('data-vary');
const profile = btn.getAttribute('data-krea-profile');
if (aspect) {
await applyQuickPatch({ aspect, actions: ['generate'] }, `Aspect ${aspect}`);
} else if (seed === 'lock') {
await applyQuickPatch({ lock_seed: true }, 'Seed locked');
} else if (seed === 'random') {
await applyQuickPatch({ seed: -1, actions: ['generate'] }, 'Seed random');
} else if (vary) {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary');
} else if (profile === 'turbo') {
const p = state.kreaProfiles?.turbo || mergedGenerationDefaults('turbo');
await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo');
} else if (profile === 'raw') {
const p = state.kreaProfiles?.raw || mergedGenerationDefaults('raw');
await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, sigma_shift: p.sigma_shift, actions: ['generate'] }, 'RAW');
}
renderLoraChips();
});
$('sa_auto_vision')?.addEventListener('change', () => {
saveSettings();
const gen = generateSlot();
if (gen) {
gen.attach = wantsAutoVision();
renderBoard();
}
});
$('sa_auto_apply')?.addEventListener('change', saveSettings);
$('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);
setInterval(renderLoraChips, 4000);
syncLiveParamsBar();
setInterval(syncLiveParamsBar, 1200);
syncModeBadge();
syncBuildGenButton();
setInterval(updateGate, 2000);
setInterval(syncGenerateSlot, 700);
setInterval(() => {
if (!state.busy && !state.generating) {
probeOllamaHealth();
}
}, 45000);
setInterval(() => {
if (!state.busy && !state.generating) {
refreshWantedQueue();
}
}, 120000);
window.addEventListener('beforeunload', () => {
try {
saveActiveChatToStore({ dropEmpty: true });
const chat = findChat(state.activeChatId);
if (chat && (chat.messages || []).length) {
diskPersist()?.saveChat(chat, { immediate: true });
}
diskPersist()?.saveUiState(collectUiState(), { immediate: true });
} catch (e) { /* ignore */ }
});
setInterval(() => {
if (!state.busy) {
const tabOn = !!document.getElementById(TAB_BUTTON_ID)?.classList.contains('tab-button-selected')
|| !!document.getElementById('swarm_assistent_root')?.offsetParent;
refreshInventory(null, { rescan: inventoryIsStale(tabOn ? 45000 : 120000) });
}
}, 30000);
window.swarmAssistent = {
setImageFromSrc,
putImageOnBoard,
clearVisionImage,
snapshotGenerateToRef,
openAssistentTab,
sendToAssistent: (src) => {
putImageOnBoard(src, { switchTab: true, note: 'Изображение отправлено в Assistent', preferSelected: false });
setBoardTab('refs');
},
isKreaSelected,
resolveCurrentCheckpoint,
refreshInventory,
applyPatch,
triggerGenerate,
setInitFromSrc,
setMaskFromSrc,
clearInitAndMask,
slotById,
renderBoard,
setBoardTab,
};
}
function wireSlashInput() {
const input = $('sa_input');
if (!input || input.dataset.saSlashWired) {
return;
}
input.dataset.saSlashWired = '1';
input.addEventListener('input', () => updateSlashMenuFromInput());
input.addEventListener('keydown', (e) => {
const menu = $('sa_slash_menu');
const open = menu && !menu.hidden;
if (open) {
const items = slashMatches((input.value.split(/\s/)[0] || ''));
if (e.key === 'ArrowDown') {
e.preventDefault();
state.slashIndex = Math.min(items.length - 1, (state.slashIndex || 0) + 1);
renderSlashMenu(items);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
state.slashIndex = Math.max(0, (state.slashIndex || 0) - 1);
renderSlashMenu(items);
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
const pick = items[state.slashIndex || 0];
if (pick && input.value.trim() === (input.value.split(/\s/)[0] || '')) {
e.preventDefault();
applySlashPick(pick);
return;
}
}
if (e.key === 'Escape') {
hideSlashMenu();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
e.preventDefault();
hideSlashMenu();
sendChat();
}
});
input.addEventListener('blur', () => setTimeout(hideSlashMenu, 150));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', wire);
} else {
wire();
}
})();