Files
swarm-assistent/Assets/assistent.js
T
Leonid PershinandCursor 6d8aaefc38 Ship Assistent 0.7.6: multi-chat history, UI polish, and race fixes.
Persist chats with Generate param snapshots, always open a new chat on start, and abort in-flight work on clear/switch so replies cannot land in the wrong chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 00:06:11 +03:00

6196 lines
238 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.7.6: Cheap-bug pass — abort on clear/new, busy until side-effects, interrupt cleans stream.
*/
(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_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';
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';
const MAX_REF_SLOTS = 4;
const CONTEXT_PROMPT_MAX = 2000;
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 = {
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,
lastPatch: null,
pendingSilentGen: false,
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,
critiqueHopUsed: false,
visionHopUsed: false,
busyPhase: 'idle',
busyStarted: 0,
gotDelta: false,
busyTimer: null,
slots: [],
selectedSlotId: 'ref1',
refSeq: 1,
packUserTouched: false,
view: 'chat',
boardTab: 'generate',
personas: [],
modelCards: {},
cardsSelection: null,
cardsBusy: false,
pendingPersonaNote: null,
chats: [],
activeChatId: null,
restoringChat: false,
chatsPanelOpen: false,
slashIndex: 0,
};
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;
}
}
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());
if (!state.gotDelta && (state.busyPhase === 'thinking' || state.busyPhase === 'waiting') && elapsed > 1600) {
state.busyPhase = 'loading';
}
const model = modelShort($('sa_model')?.value);
const labels = {
encoding: 'Encoding image…',
waiting: 'Waiting for Ollama…',
loading: `Loading ${model} into GPU… first load can take a minute`,
thinking: 'Thinking…',
streaming: 'Writing…',
generating: 'Generating image…',
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;');
}
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;
}
function userTextMentionsParams(text) {
return /\b(steps?|cfg|seed|sigma|размер|aspect|ширин|высот|resolution|batch|турбо|turbo|raw)\b/i.test(String(text || ''));
}
function userAsksGenerate(text) {
const t = String(text || '');
if (!t.trim()) {
return false;
}
if (/^(gen|generate|go|рисуй|давай|ещё|еще)\s*[!.…]*$/i.test(t.trim())) {
return true;
}
return /\b(сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|сделай\s+(картинк|изображен|фото)|run\s+generat|\/gen)\b/i.test(t);
}
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 syncModeBadge() {
const badge = $('sa_mode_badge');
const pack = $('sa_pack')?.value || 'write_prompt';
if (!badge) {
return;
}
const shortMap = {
write_prompt: 'write',
critique_image: 'critique',
compose_scene: 'compose',
fix_params: 'params',
inpaint_edit: 'inpaint',
describe_ref: 'describe',
catalog_card: 'card',
};
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();
return true;
}
const pane = document.getElementById('assistent');
if (pane && typeof bootstrap !== 'undefined' && bootstrap.Tab) {
try {
bootstrap.Tab.getOrCreateInstance(tab || pane).show();
} catch (e) { /* ignore */ }
}
return !!tab;
}
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);
}
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 isGenerateUnavailable() {
if (state.generating || state.busy) {
return true;
}
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 syncGenerateBusy() {
const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
if (overlay) {
overlay.hidden = !state.generating && state.busyPhase !== 'generating';
}
}
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();
board.classList.toggle('sa-board-many', tab === 'refs' && (refs.some((s) => s.src) || refs.length > 1));
board.classList.toggle('sa-board-gen-only', tab === 'generate');
board.innerHTML = '';
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 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;
}
}
}
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) {
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);
box.scrollTop = box.scrollHeight;
}
function chatUid() {
return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function slimHistoryMessages(list) {
return (list || [])
.filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish)
.slice(-MAX_CHAT_MSGS)
.map((m) => ({
role: m.role,
content: String(m.content || '').slice(0, 4000),
persona: m.persona || undefined,
pack: m.pack || undefined,
}));
}
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 || 'write_prompt',
sessionExact,
lastPatch,
};
}
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') {
syncBuildGenButton();
syncLiveParamsBar();
syncModeBadge();
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;
syncBuildGenButton();
syncLiveParamsBar();
syncModeBadge();
renderLoraChips();
syncChipHighlight();
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 }));
} 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(-12).map((m) => ({
...m,
content: String(m.content || '').slice(0, 1500),
})),
}));
state.chats = slim;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
} catch (e2) {
console.warn('Assistent: chats quota fallback failed', e2);
}
}
}
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();
}
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 renderChatsList() {
const root = $('sa_chats_list');
if (!root) {
return;
}
root.innerHTML = '';
syncHistoryBadge();
const chats = (state.chats || [])
.slice()
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
.filter((c) => (c.messages || []).length > 0);
if (!chats.length) {
root.innerHTML = '<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;
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();
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;
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();
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 });
const chat = findChat(id);
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;
}
persistChatsStore();
syncHistoryBadge();
if (wasActive) {
startNewChat({ saveCurrent: false, force: true });
} else {
renderChatsList();
}
}
function initChatSessions() {
loadChatsStore();
// Always open a fresh chat on startup; past chats stay in History.
startNewChat({ saveCurrent: false, force: true });
syncHistoryBadge();
}
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;
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();
}
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();
});
}
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: slimInventoryLoras(inv.loras || [], 100),
available_checkpoints: slimInventoryCheckpoints(inv.checkpoints || [], 40),
wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 60),
inventory_at: inv.inventory_at || null,
has_vision_image: attachableSlots().length > 0,
image_slots: slotCatalog(),
attached_slot_ids: attachableSlots().map((s) => s.id),
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: [],
taste_profile: summarizeTaste(),
...initCtx,
};
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)) {
ctx.selected_loras = loraHelper.selected.map((l) => ({
name: l.name || l,
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
}));
}
} catch (e) { /* ignore */ }
// Recommendation cards: checkpoint + selected LoRAs + other has_card entries (capped).
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 l of inv.loras || []) {
if (l?.has_card && l?.name) {
addKey('lora', l.name);
}
if (cardKeys.length >= 14) {
break;
}
}
for (const c of inv.checkpoints || []) {
if (c?.has_card && c?.name) {
addKey('checkpoint', c.name);
}
if (cardKeys.length >= 16) {
break;
}
}
for (const k of cardKeys) {
const cached = state.modelCards[`${k.kind}:${k.name}`];
if (cached) {
ctx.model_cards.push(slimCardForContext(cached));
}
}
// 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 > 80) {
ctx.available_loras = ctx.available_loras.slice(0, 80);
}
} 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);
ctx.recommended_params = {
steps: defaults.steps ?? 8,
cfg: defaults.cfg ?? 1,
sigma_shift: defaults.sigma_shift ?? 1.15,
};
if (defaults.aspect) {
ctx.recommended_params.aspect = defaults.aspect;
}
} catch (e) {
ctx.krea_profile = 'turbo';
const defaults = mergedGenerationDefaults('turbo');
ctx.recommended_params = {
steps: defaults.steps ?? 8,
cfg: defaults.cfg ?? 1,
sigma_shift: defaults.sigma_shift ?? 1.15,
};
}
ctx.session_exact = state.sessionExact && Object.keys(state.sessionExact).length
? { ...state.sessionExact }
: {};
// Exact KV is already in the system prompt — do not duplicate the full blob into live context.
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 softCap = Math.min(limit || 100, 80);
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)));
// Full detail for top tier; name+trigger only for the rest within softCap.
const fullDetail = 36;
return rows.slice(0, softCap).map((row, idx) => {
const out = { name: row.name, title: row.title };
if (row.trigger_phrase) {
out.trigger_phrase = row.trigger_phrase;
}
if (row.triggers) {
out.triggers = row.triggers;
}
if (row.krea_likely) {
out.krea_likely = true;
}
if (row.has_card) {
out.has_card = true;
}
const rich = idx < fullDetail || row._score >= 200;
if (rich) {
if (row.architecture) {
out.architecture = row.architecture;
}
if (row.compat_class) {
out.compat_class = row.compat_class;
}
if (row.blurb) {
out.blurb = row.blurb;
}
if (row.default_weight) {
out.default_weight = row.default_weight;
}
if (row.tags) {
out.tags = row.tags;
}
}
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 || 40).map((c) => {
const out = {
name: c.name,
title: c.title || c.name,
architecture: c.architecture || null,
compat_class: c.compat_class || null,
has_card: !!c.has_card,
krea_likely: !!c.krea_likely,
};
if (c.blurb) {
out.blurb = c.blurb;
}
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();
}
function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
return (
obj.prompt != null ||
obj.loras ||
obj.width ||
obj.height ||
obj.steps ||
obj.cfg ||
obj.seed != null ||
obj.sigma_shift != null ||
obj.sampler ||
obj.actions ||
obj.search_query ||
obj.civitai_query ||
obj.use_init_image != null ||
obj.clear_init_image != null ||
obj.init_creativity != null ||
obj.denoise != null ||
obj.use_mask_image != null ||
obj.clear_mask_image != null ||
obj.mask_blur != null ||
obj.mask_grow != null ||
obj.look_at != null ||
obj.vision_from != null ||
obj.vision_slots != null ||
obj.slot_to_init != null ||
obj.slot_to_mask != null ||
obj.snapshot_generate != null ||
obj.select_slot != null ||
obj.aspect != null ||
obj.images != null ||
obj.batch != null ||
obj.vary != null ||
obj.lock_seed != null ||
obj.creativity != null ||
obj.intensity != null ||
obj.complexity != null ||
obj.movement != null ||
obj.clear_prompt_images != null ||
obj.slot_to_prompt_image != null ||
obj.pack != null
);
}
function 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);
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 (!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) {
const raw = match[1].trim();
try {
const obj = JSON.parse(raw);
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;
}
const t = String(text || '').toLowerCase();
if (!t.trim()) {
return null;
}
if (/\b(поправь|исправь|перепиши|улучши|fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
return 'write_prompt';
}
if (/\b(опиши\s+реф|опиши\s+изображ|prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t)
|| /опиши\s+(этот|эту|картинк|референс)/i.test(t)) {
return 'describe_ref';
}
if (/\b(critique|что\s+не\s+так|посмотри|смотри|разбери|критик)\b/i.test(t)) {
return 'critique_image';
}
if (/\b(inpaint|замажь|закрась|руки|лицо|маск|mask|img2img|init\s*image)\b/i.test(t)) {
return 'inpaint_edit';
}
if (userTextMentionsParams(t)) {
return 'fix_params';
}
if (/\b(сцен|moodboard|атмосфер|compose|scene|мизансцен)\b/i.test(t)) {
return 'compose_scene';
}
return 'write_prompt';
}
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 ||
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)');
}
}
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('Applied patch');
}
}
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 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;
return new Promise((resolve) => {
const start = Date.now();
state.waitImageTimer = setInterval(() => {
if (epoch !== state.chatEpoch) {
cancelWaitForNewImage();
resolve(null);
return;
}
const src = findCurrentGenerateSrc();
if (src && src !== prevSrc && !looksLikeModelPreview(src)) {
cancelWaitForNewImage();
resolve(src);
} else if (Date.now() - start > timeoutMs) {
cancelWaitForNewImage();
resolve(null);
}
}, 400);
});
}
async function runGenerateFromPatch(patch, opts = {}) {
const force = !!opts.force;
if ((!force && !$('sa_auto_generate')?.checked) || !patchHasGenTrigger(patch)) {
return null;
}
const prev = findCurrentGenerateSrc();
setStatus('Генерация…');
startBusyUi('generating');
state.generating = true;
setInterruptVisible(true);
const ok = triggerGenerate();
if (!ok) {
state.generating = false;
setInterruptVisible(state.busy);
setStatus('Не удалось запустить Generate');
return null;
}
const src = await waitForNewImage(prev);
state.generating = false;
setInterruptVisible(state.busy);
if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
}
if (src) {
const gen = generateSlot();
if (gen) {
gen.src = src;
renderBoard();
}
setStatus('Generate готов');
return src;
}
if (state.busy) {
setStatus('Generate завершён (новое изображение не найдено)');
}
return null;
}
async function maybeAutoCritique(imageSrc) {
if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed || !imageSrc) {
return;
}
state.critiqueHopUsed = true;
const pack = $('sa_pack');
if (pack) {
pack.value = 'critique_image';
saveSettings();
}
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;
if (imageSrc) {
gen.src = imageSrc;
}
renderBoard();
}
setStatus('Auto-critique…');
await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
}
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 !== '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 setAssistantBody(div, text) {
if (!div) {
return;
}
let body = div.querySelector('.sa-msg-body');
if (!body) {
body = document.createElement('div');
body.className = 'sa-msg-body';
div.appendChild(body);
}
body.textContent = text || '';
}
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);
box.scrollTop = box.scrollHeight;
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);
box.scrollTop = box.scrollHeight;
state.streamEl = div;
state.streamMeta = meta || null;
return div;
}
function appendStreamDelta(delta) {
if (!state.streamEl) {
beginStreamMessage(state.streamMeta || undefined);
}
if (state.streamEl) {
if (state.streamEl.classList.contains('sa-typing')) {
state.streamEl.classList.remove('sa-typing');
setAssistantBody(state.streamEl, '');
}
state.gotDelta = true;
if (state.busyPhase !== 'refining') {
setBusyPhase('streaming');
}
let body = state.streamEl.querySelector('.sa-msg-body');
if (!body) {
setAssistantBody(state.streamEl, '');
body = state.streamEl.querySelector('.sa-msg-body');
}
body.textContent += delta;
const box = $('sa_messages');
if (box) {
box.scrollTop = box.scrollHeight;
}
}
}
function finalizeStreamMessage(fullReply, civitaiResults) {
const el = state.streamEl;
const meta = state.streamMeta;
state.streamEl = null;
state.streamMeta = null;
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));
}
const box = $('sa_messages');
if (box) {
box.scrollTop = box.scrollHeight;
}
}
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 = 'Installed';
actions.appendChild(note);
} else if (r.download_url) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'basic-button sa-primary';
btn.textContent = 'Confirm download';
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
actions.appendChild(btn);
} else {
const note = document.createElement('span');
note.textContent = 'No download 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 = 'Downloading…';
}
setStatus(`Downloading ${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(`Downloaded ${payload.name}`);
if (btn) {
btn.textContent = 'Downloaded';
}
refreshInventory(async () => {
await maybeWriteCardAfterDownload({
kind: 'lora',
name: payload.name,
civitai: card,
});
}, { rescan: true });
} else {
setStatus(msg || 'Download failed');
if (btn) {
btn.disabled = false;
btn.textContent = 'Confirm download';
}
appendMessage('error', msg || 'Download failed');
}
};
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() {
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 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';
}
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') {
state.view = view;
}
const boardTab = localStorage.getItem(LS_BOARD_TAB);
if (boardTab === 'refs' || boardTab === 'generate') {
state.boardTab = boardTab;
}
}
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 || 'write_prompt');
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');
persistServerSettings();
}
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;
}
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 || '',
}));
}
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;
}
if (applyDefaults || data.exact) {
fillEmptyParamsFromExact();
}
}
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;
}
}
function renderPackOptions(packs, preferred) {
const sel = $('sa_pack');
if (!sel) {
return;
}
const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || 'write_prompt';
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),
);
}
function setModelOptions(models, { error } = {}) {
const sel = $('sa_model');
if (!sel) {
return;
}
const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
sel.innerHTML = '';
if (error) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
sel.appendChild(opt);
sel.disabled = true;
return;
}
sel.disabled = false;
if (!names.length) {
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'No Ollama models — pull / Refresh';
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.preferredModel || localStorage.getItem(LS_MODEL);
if (prefer && names.includes(prefer)) {
sel.value = prefer;
}
}
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 || [];
setModelOptions(models);
setEmbedModelOptions(memoryModels);
const prefer = state.preferredModel || localStorage.getItem(LS_MODEL);
if (prefer && models.includes(prefer) && $('sa_model')) {
$('sa_model').value = prefer;
}
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
saveSettings();
},
0,
(err) => {
const msg = String(err || 'Ollama unreachable');
setStatus(msg);
setModelOptions([], { error: 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 setCardStatus(msg) {
const el = $('sa_card_status');
if (el) {
el.textContent = msg || '';
}
}
function setView(view) {
state.view = view === 'cards' ? 'cards' : 'chat';
const chat = $('sa_view_chat');
const cards = $('sa_view_cards');
if (chat) {
chat.hidden = state.view !== 'chat';
}
if (cards) {
cards.hidden = state.view !== 'cards';
}
$('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
saveSettings();
if (state.view === 'cards') {
renderCardsList();
}
}
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 (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());
},
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}`);
},
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;
if (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);
if (patch) {
rememberLastPatch(patch);
}
if (Array.isArray(patch?.actions) && patch.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);
}
}
if (patch && !fromVisionHop && !fromAutoCritique) {
const hopped = await maybeVisionHop(patch, 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 = !!(opts.userWantsGenerate || state.pendingSilentGen
|| (Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate')));
const doApply = !!(patch && (wantsGen || $('sa_auto_apply')?.checked));
if (doApply) {
if (wantsGen) {
startBusyUi('silent_gen');
} else {
setBusyPhase('applying');
}
await applyPatch(patch, 'all');
syncLiveParamsBar();
updateTasteFromPatch(patch, opts.userText || '');
if (!fromAutoCritique && (wantsGen || $('sa_auto_generate')?.checked)) {
const src = await runGenerateFromPatch(
{ ...patch, actions: Array.isArray(patch.actions) ? patch.actions : ['generate'] },
{ force: wantsGen },
);
if (src) {
await maybeAutoCritique(src);
}
} else if (!state.generating) {
stopBusyUi(wantsGen ? 'Применено' : '');
}
}
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);
box.scrollTop = box.scrollHeight;
}
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 || 'write_prompt';
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 → session_exact → exact(+persona) → live UI → 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)}`,
` 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}`,
'',
'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 === 'civitai') {
if (!arg) {
setStatus('/civitai <query>');
return true;
}
if ($('sa_input')) {
$('sa_input').value = `Find a Krea 2 LoRA for: ${arg}`;
}
setPackValue('write_prompt', { flash: true });
await sendChat({
skipAutoPack: true,
forcedUserText: `Search Civitai for Krea-compatible LoRA: ${arg}. Prefer actions search_civitai.`,
});
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.pendingSilentGen = userAsksGenerate(text);
}
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;
}
}
}
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 || 'write_prompt';
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;
setInterruptVisible(true);
startBusyUi('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);
if (!wantedIds.length) {
wantedIds = attachableSlots().map((s) => s.id);
}
if (wantsAutoVision() && generateSlot()?.src && !looksLikeModelPreview(generateSlot().src) && !wantedIds.includes(GEN_ID)) {
wantedIds.push(GEN_ID);
}
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();
context.has_vision_image = !!images;
context.attached_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(-12).map((m) => ({ role: m.role, content: m.content }));
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 || '',
raw: {
messages,
context_json: JSON.stringify(context),
pack,
persona,
base_url: baseUrl,
model,
skills: state.enabledSkills || [],
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
},
};
const finishOk = async (reply, civitaiResults) => {
if (chatEpoch !== state.chatEpoch) {
return;
}
const prose = extractPatch(reply).prose || reply;
state.history.push({ role: 'assistant', content: prose, persona, pack });
persistHistory();
setBusyPhase(state.pendingSilentGen || userAsksGenerate(text) ? 'silent_gen' : 'thinking');
try {
await handleReplySideEffects(reply, civitaiResults, {
...opts,
userText: text,
userWantsGenerate: !!state.pendingSilentGen || userAsksGenerate(text),
attachedSlotIds: visionSlots.map((s) => s.id),
});
} finally {
if (chatEpoch !== state.chatEpoch) {
return;
}
state.busy = false;
setInterruptVisible(state.generating);
if (!state.generating) {
stopBusyUi('Готово');
}
}
};
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') {
setBusyPhase('loading');
const label = state.streamEl?.querySelector('.sa-typing-label');
if (label) {
label.textContent = `Загружаю ${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);
}
},
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 || []);
},
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 || []);
},
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 === '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 = '';
});
}
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 === 'write_prompt') {
pack.value = 'critique_image';
saveSettings();
}
},
'Open Assistent with this image (vision / critique / prompt help)',
['image'],
true,
true,
);
}
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();
}
initChatSessions();
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
refreshModels();
refreshInventory(() => {
renderCardsList();
renderLoraChips();
});
});
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);
}
});
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
$('sa_persona')?.addEventListener('change', onPersonaChanged);
$('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', () => {
const s = $('sa_settings');
if (s) {
s.hidden = !s.hidden;
}
});
$('sa_btn_refresh_models')?.addEventListener('click', () => {
saveSettings();
refreshModels();
});
$('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 === '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', 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);
syncChipHighlight();
setInterval(syncChipHighlight, 2500);
setInterval(renderLoraChips, 4000);
syncLiveParamsBar();
setInterval(syncLiveParamsBar, 1200);
syncModeBadge();
syncBuildGenButton();
setInterval(updateGate, 2000);
setInterval(syncGenerateSlot, 700);
window.addEventListener('beforeunload', () => {
try {
saveActiveChatToStore({ dropEmpty: 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();
}
})();