diff --git a/Assets/assistent.css b/Assets/assistent.css
index f01e22a..271cd70 100644
--- a/Assets/assistent.css
+++ b/Assets/assistent.css
@@ -5,6 +5,7 @@
min-height: 28rem;
padding: 0.5rem 0.65rem 0.65rem;
box-sizing: border-box;
+ position: relative;
}
.sa-gate {
@@ -1847,6 +1848,137 @@
align-items: center;
}
+.sa-board.sa-board-variants {
+ grid-template-columns: 1fr 1fr;
+ grid-auto-rows: minmax(7.5rem, 1fr);
+}
+
+.sa-slot.sa-slot-gen-result .sa-slot-open {
+ appearance: none;
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+ background: color-mix(in srgb, currentColor 10%, transparent);
+ color: inherit;
+ border-radius: 999px;
+ padding: 0.05rem 0.45rem;
+ font-size: 0.68rem;
+ cursor: pointer;
+ margin-left: auto;
+ opacity: 0.85;
+}
+
+.sa-slot.sa-slot-gen-result .sa-slot-open:hover {
+ opacity: 1;
+}
+
+.sa-lightbox {
+ position: absolute;
+ inset: 0;
+ z-index: 80;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ pointer-events: auto;
+}
+
+.sa-lightbox[hidden] {
+ display: none !important;
+}
+
+.sa-lightbox-backdrop {
+ position: absolute;
+ inset: 0;
+ background: color-mix(in srgb, #000 62%, transparent);
+}
+
+.sa-lightbox-panel {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem;
+ width: min(96%, 52rem);
+ max-height: 94%;
+ padding: 0.55rem 0.65rem 0.65rem;
+ border-radius: 0.65rem;
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+ background: color-mix(in srgb, Canvas 92%, transparent);
+ box-shadow: 0 12px 40px color-mix(in srgb, #000 35%, transparent);
+}
+
+.sa-lightbox-head {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.sa-lightbox-title {
+ font-weight: 650;
+ font-size: 0.9rem;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sa-lightbox-idx {
+ font-size: 0.75rem;
+ opacity: 0.7;
+}
+
+.sa-lightbox-body {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 12rem;
+ flex: 1;
+ overflow: hidden;
+}
+
+.sa-lightbox-body img {
+ max-width: 100%;
+ max-height: min(70vh, 36rem);
+ object-fit: contain;
+ border-radius: 0.35rem;
+}
+
+.sa-lightbox-nav {
+ appearance: none;
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 2rem;
+ height: 2.4rem;
+ border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
+ border-radius: 0.4rem;
+ background: color-mix(in srgb, Canvas 80%, transparent);
+ color: inherit;
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0.85;
+}
+
+.sa-lightbox-nav:hover {
+ opacity: 1;
+}
+
+.sa-lightbox-prev {
+ left: 0.25rem;
+}
+
+.sa-lightbox-next {
+ right: 0.25rem;
+}
+
+.sa-lightbox-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ justify-content: flex-end;
+}
+
@media (max-width: 900px) {
.sa-layout {
flex-direction: column;
@@ -1872,4 +2004,8 @@
border-right: none;
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
}
+ .sa-lightbox-panel {
+ width: 98%;
+ max-height: 96%;
+ }
}
diff --git a/Assets/assistent.js b/Assets/assistent.js
index 4a33d0b..e12fdf0 100644
--- a/Assets/assistent.js
+++ b/Assets/assistent.js
@@ -1,8671 +1,9391 @@
-/**
- * Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
- * v0.8.0: Split assets — SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
- */
-(function () {
- const LS_BASE = 'swarm_assistent_base_url';
- const LS_MODEL = 'swarm_assistent_model';
- const LS_EMBED = 'swarm_assistent_embed_model';
- const LS_PACK = 'swarm_assistent_pack';
- const LS_PACK_ORDINARY_MIG = 'swarm_assistent_pack_ordinary_v1';
- /** One-shot: drop junior chat tags stuck in LS so UI/warm pick preferred/senior. */
- const LS_MODEL_SENIOR_MIG = 'swarm_assistent_model_senior_v1';
- const LS_PERSONA = 'swarm_assistent_persona';
- const LS_VIEW = 'swarm_assistent_view';
- const LS_AUTO_VISION = 'swarm_assistent_auto_vision';
- const LS_AUTO_APPLY = 'swarm_assistent_auto_apply';
- const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate';
- const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique';
- const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download';
- /** When '1', unload chat LLM before Generate (frees VRAM; VL reload can take 1–2 min). Default off. */
- const LS_PARK_LLM = 'swarm_assistent_park_llm';
- const LS_PANE_WIDTH = 'swarm_assistent_pane_width';
- const LS_WELCOMED = 'swarm_assistent_welcomed';
- const LS_TASTE = 'swarm_assistent_taste';
- const LS_HISTORY = 'swarm_assistent_history';
- const LS_CHATS = 'swarm_assistent_chats_v1';
- const LS_BOARD_TAB = 'swarm_assistent_board_tab';
- const MAX_CHATS = 40;
- const MAX_CHAT_MSGS = 24;
- const TAB_BUTTON_ID = 'maintab_assistent';
- const GEN_ID = 'generate';
- let MAX_REF_SLOTS = 4;
- let CONTEXT_PROMPT_MAX = 2000;
- let HISTORY_KEEP_TURNS = 4;
- let INVENTORY_PROMPT_RICH = 12;
- let INVENTORY_PROMPT_NAMES = 24;
-
- let ASPECT_TABLE = {
- '1:1': [1024, 1024],
- '4:3': [1184, 896],
- '3:2': [1248, 832],
- '16:9': [1376, 768],
- '2.35:1': [1568, 672],
- '4:5': [928, 1152],
- '2:3': [832, 1248],
- '9:16': [768, 1376],
- };
-
- let PACK_ALIASES = {
- ordinary: 'ordinary',
- combine: 'ordinary',
- normal: 'ordinary',
- general: 'ordinary',
- default: 'ordinary',
- write: 'write_prompt',
- write_prompt: 'write_prompt',
- critique: 'critique_image',
- critique_image: 'critique_image',
- compose: 'compose_scene',
- compose_scene: 'compose_scene',
- params: 'fix_params',
- fix_params: 'fix_params',
- inpaint: 'inpaint_edit',
- inpaint_edit: 'inpaint_edit',
- describe: 'describe_ref',
- describe_ref: 'describe_ref',
- card: 'catalog_card',
- catalog: 'catalog_card',
- catalog_card: 'catalog_card',
- };
-
- let WELCOME_HTML = `
-
Assistent · Krea 2
-
- Generate слева — живой просмотр. В чат сам не уходит.
- Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.
- Галочка vision на окне — отправить кадр модели.
- Чипсы aspect / seed / Vary / Turbo·RAW. В чате: /help.
- Кнопки патча только у последнего предложения.
-
- Напиши, что сгенерировать — или кинь референс и попроси правку.`;
-
- 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 — поиск LoRA (Confirm в чате)
-/inventory — rescan моделей + обновить список LoRA
-
-Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.
-При старте всегда новый чат; смена чата в Истории восстанавливает параметры.`;
-
- let SLASH_COMMANDS = [
- { cmd: '/help', hint: 'список команд' },
- { cmd: '/new', hint: 'новый чат' },
- { cmd: '/history', hint: 'история чатов' },
- { cmd: '/debug', hint: 'сводка · ask = с LLM' },
- { cmd: '/why', hint: 'debug + пояснение LLM' },
- { cmd: '/gen', hint: 'Generate сейчас' },
- { cmd: '/look ', hint: 'generate|refN' },
- { cmd: '/init', hint: 'как Init' },
- { cmd: '/mask', hint: 'как Mask' },
- { cmd: '/clear', hint: 'сброс Init/Mask' },
- { cmd: '/interrupt', hint: 'стоп' },
- { cmd: '/aspect ', hint: '16:9' },
- { cmd: '/seed ', hint: 'lock|random' },
- { cmd: '/vary', hint: 'новый seed' },
- { cmd: '/pack ', hint: 'write|critique|…' },
- { cmd: '/civitai ', hint: 'запрос LoRA' },
- { cmd: '/inventory', hint: 'rescan моделей' },
- ];
-
- const state = {
- history: [],
- packsLoaded: false,
- config: null,
- exact: null,
- sessionExact: {},
- lastUserParamIntent: false,
- lastUserControlIntent: false,
- lastPatch: null,
- pendingSilentGen: false,
- enabledSkills: [],
- kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } },
- preferredEmbed: null,
- busy: false,
- generating: false,
- chatEpoch: 0,
- waitImageTimer: null,
- lastImageDataUrl: null,
- preferredModel: null,
- dragDepth: 0,
- inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
- inventoryFetchedAt: 0,
- taste: { styles: [], likes: [], avoid: [], notes: '', updated: 0 },
- tasteSaveTimer: null,
- streamEl: null,
- streamMeta: null,
- streamText: '',
- streamFenceDone: false,
- critiqueHopUsed: false,
- visionHopUsed: false,
- lastSystemChars: 0,
- lastSystemLayers: null,
- lastContextChars: 0,
- busyPhase: 'idle',
- busyStarted: 0,
- gotDelta: false,
- busyTimer: null,
- slots: [],
- selectedSlotId: 'ref1',
- refSeq: 1,
- packUserTouched: false,
- view: 'chat',
- boardTab: 'generate',
- personas: [],
- modelCards: {},
- cardsSelection: null,
- cardsBusy: false,
- pendingPersonaNote: null,
- chats: [],
- activeChatId: null,
- restoringChat: false,
- chatsPanelOpen: false,
- chatsQuery: '',
- chatsSearchHits: null,
- slashIndex: 0,
- llmParked: false,
- expectColdLoad: false,
- memoryRows: [],
- userPrefs: [],
- settingsTab: 'behavior',
- settingsPersonaId: null,
- wanted: { count: 0, items: [] },
- wantedKeys: new Set(),
- ollamaHealth: 'unknown',
- };
-
- /** Disk persistence module (assistent.persist.js) — absent means localStorage only. */
- function diskPersist() {
- return (window.SA && window.SA.persist) || null;
- }
-
- function $(id) {
- return document.getElementById(id);
- }
-
- function modelShort(name) {
- const s = String(name || '');
- const slash = s.lastIndexOf('/');
- return (slash >= 0 ? s.slice(slash + 1) : s) || 'model';
- }
-
- function fmtElapsed(ms) {
- const s = Math.max(0, Math.floor(ms / 1000));
- if (s < 60) {
- return `${s}s`;
- }
- return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
- }
-
- function hideChatEmpty() {
- const empty = $('sa_chat_empty');
- if (empty) {
- empty.hidden = true;
- }
- }
-
- let scrollMessagesRaf = 0;
-
- /** Autoscroll only when already near the bottom — otherwise streaming fights the user's scroll (shakes). */
- function messagesNearBottom(thresholdPx = 96) {
- const box = $('sa_messages');
- if (!box) {
- return true;
- }
- return (box.scrollHeight - box.scrollTop - box.clientHeight) <= thresholdPx;
- }
-
- function scrollMessagesToBottom({ force = false } = {}) {
- const box = $('sa_messages');
- if (!box) {
- return;
- }
- if (!force && !messagesNearBottom()) {
- return;
- }
- if (scrollMessagesRaf) {
- return;
- }
- scrollMessagesRaf = requestAnimationFrame(() => {
- scrollMessagesRaf = 0;
- const el = $('sa_messages');
- if (el && (force || messagesNearBottom(120))) {
- el.scrollTop = el.scrollHeight;
- }
- });
- }
-
- function showChatEmptyIfIdle() {
- const box = $('sa_messages');
- const empty = $('sa_chat_empty');
- if (!box || !empty) {
- return;
- }
- const hasMsg = [...box.children].some((el) => el.id !== 'sa_chat_empty');
- empty.hidden = hasMsg;
- }
-
- function setBusyPhase(phase) {
- state.busyPhase = phase || 'thinking';
- tickBusyUi();
- syncPatchActionAvailability();
- syncGenerateBusy();
- }
-
- function tickBusyUi() {
- if (state.busyPhase === 'idle') {
- return;
- }
- const elapsed = Date.now() - (state.busyStarted || Date.now());
- // Only claim "Loading into GPU" when the model was parked / known cold.
- // Otherwise a slow first token on an already-resident VL looks like a 2‑min reload.
- if (!state.gotDelta && (state.busyPhase === 'thinking' || state.busyPhase === 'waiting') && elapsed > 1600) {
- state.busyPhase = state.llmParked || state.expectColdLoad ? 'loading' : 'waiting';
- }
- const model = modelShort($('sa_model')?.value);
- const labels = {
- encoding: 'Encoding image…',
- waiting: 'Жду Ollama / первый токен…',
- loading: `Загружаю ${model} в GPU… обычно 30–120 с после park`,
- warming: `Возвращаю ${model} в GPU…`,
- thinking: 'Thinking…',
- streaming: 'Writing…',
- generating: 'Generating image…',
- parking: 'Освобождаю VRAM (park LLM)…',
- applying: 'Applying patch…',
- silent_gen: 'Применяю патч → Generate…',
- refining: 'Civitai search done — refining…',
- };
- const text = labels[state.busyPhase] || 'Working…';
- const barText = $('sa_livebar_text');
- if (barText) {
- barText.textContent = text;
- }
- const elapsedEl = $('sa_elapsed');
- if (elapsedEl) {
- elapsedEl.textContent = fmtElapsed(elapsed);
- }
- const status = $('sa_status');
- if (status) {
- status.textContent = text;
- status.classList.add('sa-status-busy');
- }
- }
-
- function startBusyUi(phase) {
- state.busyStarted = Date.now();
- state.gotDelta = false;
- state.busyPhase = phase || 'thinking';
- $('swarm_assistent_root')?.classList.add('sa-is-busy');
- $('sa_composer')?.classList.add('sa-composer-busy');
- const send = $('sa_btn_send');
- if (send) {
- send.disabled = true;
- }
- const input = $('sa_input');
- if (input) {
- input.classList.add('sa-input-busy');
- }
- const bar = $('sa_livebar');
- if (bar) {
- bar.hidden = false;
- }
- const dot = $('sa_live_dot');
- if (dot) {
- dot.hidden = false;
- }
- tickBusyUi();
- syncPatchActionAvailability();
- syncGenerateBusy();
- if (state.busyTimer) {
- clearInterval(state.busyTimer);
- }
- state.busyTimer = setInterval(tickBusyUi, 400);
- }
-
- function stopBusyUi(finalStatus) {
- if (state.busyTimer) {
- clearInterval(state.busyTimer);
- state.busyTimer = null;
- }
- const elapsed = Date.now() - (state.busyStarted || Date.now());
- state.busyPhase = 'idle';
- $('swarm_assistent_root')?.classList.remove('sa-is-busy');
- $('sa_composer')?.classList.remove('sa-composer-busy');
- const send = $('sa_btn_send');
- if (send) {
- send.disabled = false;
- }
- const input = $('sa_input');
- if (input) {
- input.classList.remove('sa-input-busy');
- }
- const bar = $('sa_livebar');
- if (bar) {
- bar.hidden = true;
- }
- const dot = $('sa_live_dot');
- if (dot) {
- dot.hidden = true;
- }
- const status = $('sa_status');
- if (status) {
- status.classList.remove('sa-status-busy');
- }
- if (finalStatus != null) {
- const suffix = elapsed >= 1000 ? ` · ${fmtElapsed(elapsed)}` : '';
- setStatus(finalStatus + suffix);
- }
- syncPatchActionAvailability();
- syncGenerateBusy();
- }
-
- function setStatus(text) {
- const el = $('sa_status');
- if (el) {
- el.textContent = text || '';
- }
- }
-
- function setInterruptVisible(on) {
- const btn = $('sa_btn_interrupt');
- if (btn) {
- btn.hidden = !on;
- btn.classList.toggle('sa-interrupt-active', !!on);
- }
- }
-
- function looksLikeKrea(text) {
- const s = String(text || '');
- return /krea\s*2|krea2|krea-2/i.test(s) || /krea/i.test(s);
- }
-
- function resolveCurrentCheckpoint() {
- const out = {
- name: null,
- architecture: null,
- compat_class: null,
- title: null,
- class: null,
- source: null,
- };
- try {
- if (typeof currentModelHelper !== 'undefined' && currentModelHelper) {
- out.name = currentModelHelper.curModel || null;
- out.architecture = currentModelHelper.curArch || null;
- out.compat_class = currentModelHelper.curCompatClass || null;
- out.source = 'currentModelHelper';
- }
- } catch (e) { /* ignore */ }
-
- try {
- if (typeof getCurrentModel === 'function') {
- const model = getCurrentModel();
- if (model) {
- out.name = out.name || model.name || null;
- out.title = model.title || null;
- out.architecture = out.architecture || model.architecture || null;
- out.class = model.class || null;
- out.compat_class = out.compat_class || model.compat_class || null;
- out.source = out.source || 'getCurrentModel';
- }
- }
- } catch (e) { /* ignore */ }
-
- try {
- const sel =
- document.getElementById('current_model') ||
- document.getElementById('input_model');
- if (sel) {
- const opt = sel.selectedOptions && sel.selectedOptions[0];
- const hint = [
- sel.value,
- opt && opt.text,
- opt && opt.dataset && opt.dataset.cleanname,
- ]
- .filter(Boolean)
- .join(' ');
- if (!out.name && sel.value) {
- out.name = sel.value;
- out.source = out.source || 'dropdown';
- }
- if (hint && !out.architecture) {
- out.title = out.title || hint;
- }
- }
- } catch (e) { /* ignore */ }
-
- return out;
- }
-
- function isKreaSelected() {
- try {
- const m = resolveCurrentCheckpoint();
- const blob = [
- m.architecture,
- m.compat_class,
- m.title,
- m.name,
- m.class,
- ].join(' ');
- return looksLikeKrea(blob);
- } catch (e) {
- return false;
- }
- }
-
- function updateGate() {
- const ok = isKreaSelected();
- const gate = $('sa_gate');
- const layout = $('sa_layout');
- if (gate) {
- gate.hidden = ok;
- if (!ok) {
- const m = resolveCurrentCheckpoint();
- const seen = [m.architecture, m.compat_class, m.name]
- .filter(Boolean)
- .join(' · ');
- const p = gate.querySelector('p');
- if (p) {
- p.innerHTML = seen
- ? `Swarm Assistent is for Krea 2 models only. Current: ${escapeHtml(
- seen,
- )} — pick a checkpoint with architecture krea-2.`
- : 'Swarm Assistent is for Krea 2 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, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
- }
-
- /** Known ### headings → RU display labels. JSON Patch is hidden (real UI is the patch strip). */
- const PROSE_SECTION_TITLES = {
- critique: 'Критика',
- критика: 'Критика',
- analysis: 'Разбор',
- разбор: 'Разбор',
- notes: 'Заметки',
- заметки: 'Заметки',
- summary: 'Кратко',
- кратко: 'Кратко',
- prompt: 'Промпт',
- промпт: 'Промпт',
- 'improved prompt': 'Промпт',
- 'next prompt': 'Промпт',
- deliverable: 'Итог',
- итог: 'Итог',
- verdict: 'Вердикт',
- вердикт: 'Вердикт',
- issues: 'Проблемы',
- проблемы: 'Проблемы',
- fixes: 'Правки',
- правки: 'Правки',
- suggestion: 'Предложение',
- suggestions: 'Предложения',
- предложения: 'Предложения',
- };
-
- function localizeProseHeading(raw) {
- const cleaned = String(raw || '').replace(/[*_`#]/g, '').trim();
- if (!cleaned) {
- return null;
- }
- const key = cleaned.toLowerCase().replace(/\s+/g, ' ');
- if (/^json\s*patch$/.test(key) || /^патч$/.test(key) || /^json\s*патч$/.test(key)) {
- return null;
- }
- if (PROSE_SECTION_TITLES[key]) {
- return PROSE_SECTION_TITLES[key];
- }
- // "Critique — blur" → take first token bucket
- const head = key.split(/[—:\-|]/)[0].trim();
- if (PROSE_SECTION_TITLES[head]) {
- return PROSE_SECTION_TITLES[head];
- }
- return cleaned;
- }
-
- function formatProseInline(escapedLine) {
- let t = escapedLine;
- t = t.replace(/`([^`]+)`/g, '$1');
- t = t.replace(/\*\*([^*]+)\*\*/g, '$1 ');
- t = t.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2 ');
- return t;
- }
-
- /** Lightweight chat prose: ### Critique → «Критика», lists, bold — not a full markdown engine. */
- function formatAssistantProseHtml(raw) {
- let text = String(raw || '').replace(/\r\n/g, '\n');
- text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n');
- text = text.replace(/\n{3,}/g, '\n\n').trim();
- if (!text) {
- return '';
- }
- const lines = text.split('\n');
- const parts = [];
- let listItems = [];
- const flushList = () => {
- if (!listItems.length) {
- return;
- }
- parts.push(
- `${listItems.map((li) => `${formatProseInline(escapeHtml(li))} `).join('')} `,
- );
- listItems = [];
- };
- for (const line of lines) {
- const heading = line.match(/^#{1,3}\s+(.+?)\s*$/);
- if (heading) {
- flushList();
- const title = localizeProseHeading(heading[1]);
- if (!title) {
- continue;
- }
- const level = Math.min((line.match(/^#+/) || ['###'])[0].length, 3);
- parts.push(
- `${escapeHtml(title)}
`,
- );
- continue;
- }
- const bullet = line.match(/^\s*[-*•]\s+(.+)$/);
- if (bullet) {
- listItems.push(bullet[1]);
- continue;
- }
- flushList();
- if (!line.trim()) {
- parts.push('
');
- continue;
- }
- parts.push(`${formatProseInline(escapeHtml(line))}
`);
- }
- flushList();
- return parts.join('');
- }
-
- function setAssistantBody(div, text, { live = false } = {}) {
- if (!div) {
- return;
- }
- let body = div.querySelector('.sa-msg-body');
- if (!body) {
- body = document.createElement('div');
- body.className = 'sa-msg-body';
- div.appendChild(body);
- }
- const raw = text || '';
- // While streaming, plain text — full HTML reformat every token reflows and shakes scroll.
- if (live) {
- body.classList.add('sa-prose', 'sa-prose-live');
- body.classList.remove('sa-prose-rich');
- body.textContent = raw;
- return;
- }
- body.classList.add('sa-prose', 'sa-prose-rich');
- body.classList.remove('sa-prose-live');
- const html = formatAssistantProseHtml(raw);
- if (html) {
- body.innerHTML = html;
- } else {
- body.textContent = '';
- }
- }
-
- function val(id) {
- const el = document.getElementById(id);
- return el ? el.value : '';
- }
-
- function setVal(id, value) {
- const el = document.getElementById(id);
- if (!el) {
- return;
- }
- el.value = value;
- el.dispatchEvent(new Event('input', { bubbles: true }));
- el.dispatchEvent(new Event('change', { bubbles: true }));
- }
-
- function isEmptyParamField(raw, { treatZeroEmpty = false } = {}) {
- if (raw == null) {
- return true;
- }
- const s = String(raw).trim();
- if (s === '') {
- return true;
- }
- if (treatZeroEmpty && (s === '0' || Number(s) === 0)) {
- return true;
- }
- return false;
- }
-
- /** JS \\b/\\w are ASCII-only — use this for RU tokens. `alts` = regex alternatives without outer parens. */
- function cyrTokenRe(alts) {
- const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
- const end = '(?=$|[^0-9A-Za-z_А-Яа-яЁё])';
- return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
- }
-
- /** Parse aspect from chat: 9:16, 9x16, 9×16, «9 на 16», «9 к 16». */
- function parseAspectFromUserText(text) {
- const t = String(text || '');
- if (!t.trim()) {
- return null;
- }
- const ratio = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*[:x×хX]\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/);
- if (ratio) {
- const key = normalizeAspect(`${ratio[1]}:${ratio[2]}`);
- if (key) {
- return key;
- }
- }
- const na = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*(?:на|к|to)\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/i);
- if (na) {
- const key = normalizeAspect(`${na[1]}:${na[2]}`);
- if (key) {
- return key;
- }
- }
- const named = t.match(/\b(16:9|9:16|1:1|4:5|2:3|3:2|4:3|2\.35:1)\b/i);
- if (named) {
- return normalizeAspect(named[1]);
- }
- if (cyrTokenRe('портрет|вертикал[а-яё]*').test(t) || /\b(portrait|vertical)\b/i.test(t)) {
- return normalizeAspect('9:16') || normalizeAspect('2:3');
- }
- if (cyrTokenRe('альбом|горизонтал[а-яё]*').test(t) || /\b(landscape|horizontal|widescreen)\b/i.test(t)) {
- return normalizeAspect('16:9');
- }
- return null;
- }
-
- /** «такую же, только 9 на 16» — keep prompt, change aspect, generate (no LLM needed). */
- function isSameButAspectRequest(text) {
- const t = String(text || '');
- if (!parseAspectFromUserText(t)) {
- return false;
- }
- return /такую\s+же|тот\s+же\s+промпт|same\s+(one|prompt|thing|again)|только\s+(поменя|смени|поставь)|поменяй\s+на|смени\s+на|only\s+change|just\s+change/i.test(t)
- || /поменяй\s+(размер|aspect|соотношен)/i.test(t)
- || /смени\s+(размер|aspect|соотношен)/i.test(t);
- }
-
- function userTextMentionsControls(text) {
- const t = String(text || '');
- if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) {
- return true;
- }
- // Do not match bare «вкус» — too common in RU chat and was disabling the echo filter.
- return cyrTokenRe('хорни|остынь|слайдер').test(t)
- || /\/\s*(остынь|ostyn|horny-game)/i.test(t)
- || /слайдер\s*вкус|вкус\s*(на|в)\s*\d|поставь\s*вкус|крутани\s*вкус/i.test(t);
- }
-
- function patchLooksLikeGeneration(patch) {
- if (!patch || typeof patch !== 'object') {
- return false;
- }
- if (patch.prompt != null || patch.loras || patch.aspect != null
- || patch.width != null || patch.height != null || patch.steps != null
- || patch.cfg != null || patch.seed != null) {
- return true;
- }
- return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
- }
-
- /** Drop Generate-patch control noise; drop default-echo that would wipe a user-tuned slider. */
- function filterControlPatch(incoming, patch) {
- const schema = state.config?.controls || {};
- const out = {};
- if (!incoming || typeof incoming !== 'object') {
- return out;
- }
- // Image patches must not move Хорни / Вкус unless the user asked this turn.
- if (patchLooksLikeGeneration(patch) && !state.lastUserControlIntent) {
- return out;
- }
- for (const [id, raw] of Object.entries(incoming)) {
- if (!schema[id]) {
- continue;
- }
- const n = Number(raw);
- if (!Number.isFinite(n)) {
- continue;
- }
- const def = Number(schema[id]?.default);
- const cur = getControlValue(id, Number.isFinite(def) ? def : n);
- if (Math.abs(n - cur) < 0.0005) {
- continue;
- }
- if (!state.lastUserControlIntent
- && Number.isFinite(def)
- && Math.abs(n - def) < 0.0005
- && Math.abs(cur - def) > 0.0005) {
- continue;
- }
- out[id] = n;
- }
- return out;
- }
-
- function userTextMentionsParams(text) {
- const t = String(text || '');
- if (parseAspectFromUserText(t)) {
- return true;
- }
- if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) {
- return true;
- }
- return cyrTokenRe('размер|ширин[а-яё]*|высот[а-яё]*|соотношен[а-яё]*|турбо|портрет|вертикал[а-яё]*').test(t);
- }
-
- function replyMissingJsonPatch(reply) {
- const t = String(reply || '');
- if (!t.trim()) {
- return false;
- }
- if (/```(?:json)?\s*\{[\s\S]*?\}```/i.test(t)) {
- return false;
- }
- return /###\s*JSON\s*Patch\b/i.test(t) || /JSON\s*Patch\s*:?\s*$/im.test(t);
- }
-
- function userAsksGenerate(text) {
- const t = String(text || '').trim();
- if (!t) {
- return false;
- }
- // Short imperatives only — do NOT treat bare «давай» as Generate (false positive on chat).
- if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
- return true;
- }
- if (/^(ещё|еще)(\s+раз)?([!.…\s]|$)/i.test(t)) {
- return true;
- }
- // Do NOT use \b or \w — ASCII-only in JS; breaks «сделай картинку».
- const letter = '[0-9A-Za-z_А-Яа-яЁё]';
- const stem = `${letter}*`;
- return cyrTokenRe(
- 'сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|'
- + `сделай\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
- + `хочу\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
- + 'run\\s+generat|/gen',
- ).test(t);
- }
-
- /** «запомни как базовый промпт» — apply/save only, never Generate / auto look_at. */
- function userAsksNoGenerate(text) {
- const t = String(text || '').trim();
- if (!t || userAsksGenerate(t)) {
- return false;
- }
- if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
- return true;
- }
- return cyrTokenRe(
- 'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
- + 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
- + 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
- + 'не\\s+рисуй|не\\s+запускай\\s+генер',
- ).test(t);
- }
-
- function stripGenerateAction(patch) {
- if (!patch || typeof patch !== 'object') {
- return patch;
- }
- if (!Array.isArray(patch.actions)) {
- return patch;
- }
- const next = patch.actions.map(String).filter((a) => a !== 'generate');
- if (next.length === patch.actions.length) {
- return patch;
- }
- const out = { ...patch };
- if (next.length) {
- out.actions = next;
- } else {
- delete out.actions;
- }
- return out;
- }
-
- function stripLookAt(patch) {
- if (!patch || typeof patch !== 'object') {
- return patch;
- }
- if (patch.look_at == null && patch.vision_from == null && patch.vision_slots == null) {
- return patch;
- }
- const out = { ...patch };
- delete out.look_at;
- delete out.vision_from;
- delete out.vision_slots;
- return out;
- }
-
- function rememberLastPatch(patch) {
- if (patch && typeof patch === 'object' && !isCardObject(patch)) {
- state.lastPatch = patch;
- syncBuildGenButton();
- }
- }
-
- function syncBuildGenButton() {
- const btn = $('sa_btn_build_gen');
- if (!btn) {
- return;
- }
- if (state.lastPatch) {
- const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null).slice(0, 6);
- btn.title = `Есть патч Assistent (${keys.join(', ') || '…'}) → Apply + Generate`;
- btn.classList.add('sa-has-patch');
- } else {
- btn.title = 'Нет патча — Generate с текущим промптом SwarmUI';
- btn.classList.remove('sa-has-patch');
- }
- }
-
- function defaultPackId() {
- return state.config?.assistant?.default_pack
- || $('sa_pack')?.querySelector('option')?.value
- || 'ordinary';
- }
-
- function syncModeBadge() {
- const badge = $('sa_mode_badge');
- const pack = $('sa_pack')?.value || defaultPackId();
- if (!badge) {
- return;
- }
- const shortMap = {
- ordinary: 'обычный',
- write_prompt: 'write',
- critique_image: 'critique',
- compose_scene: 'compose',
- fix_params: 'params',
- inpaint_edit: 'inpaint',
- describe_ref: 'describe',
- catalog_card: 'card',
- author_persona: 'persona',
- };
- const short = shortMap[pack] || pack.replace(/_/g, ' ').slice(0, 12);
- badge.textContent = short;
- badge.dataset.pack = pack;
- badge.title = `Режим: ${pack}`;
- badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit');
- }
-
- function syncLiveParamsBar() {
- const el = $('sa_live_params');
- if (!el) {
- return;
- }
- const w = parseInt(val('input_width') || '0', 10) || null;
- const h = parseInt(val('input_height') || '0', 10) || null;
- const aspect = guessAspectFromSize(w, h) || '—';
- const steps = val('input_steps') || '—';
- const cfg = val('input_cfgscale') || val('input_cfg') || '—';
- const seed = val('input_seed') || '—';
- const profile = detectKreaProfileName();
- el.textContent = `${aspect} · ${w || '?'}×${h || '?'} · steps ${steps} · cfg ${cfg} · ${profile} · seed ${seed}`;
- }
-
- function applyAspectTableFrom(obj) {
- if (!obj || typeof obj !== 'object') {
- return false;
- }
- const next = {};
- for (const [k, v] of Object.entries(obj)) {
- if (Array.isArray(v) && v.length >= 2) {
- next[k] = [Number(v[0]), Number(v[1])];
- }
- }
- if (!Object.keys(next).length) {
- return false;
- }
- ASPECT_TABLE = next;
- return true;
- }
-
- function resolveExactBundle() {
- const exact = state.exact || state.config?.exact || {};
- const profiles = exact.profiles || state.kreaProfiles || {};
- return { exact, profiles };
- }
-
- function detectKreaProfileName() {
- try {
- const model = resolveCurrentCheckpoint();
- const blob = `${model?.name || ''} ${model?.title || ''}`.toLowerCase();
- const hasRaw = /\braw\b/.test(blob);
- const hasTurbo = /\bturbo\b/.test(blob);
- return hasRaw && !hasTurbo ? 'raw' : 'turbo';
- } catch (e) {
- return (state.exact?.generation?.profile) || 'turbo';
- }
- }
-
- function mergedGenerationDefaults(profileName) {
- const { exact, profiles } = resolveExactBundle();
- const gen = exact.generation && typeof exact.generation === 'object' ? { ...exact.generation } : {};
- const profile = profileName || gen.profile || detectKreaProfileName();
- const fromProfile = profiles[profile] && typeof profiles[profile] === 'object' ? { ...profiles[profile] } : {};
- const session = state.sessionExact && typeof state.sessionExact === 'object' ? { ...state.sessionExact } : {};
- // Profile (turbo/raw) overrides generation defaults; session overrides both.
- return { ...gen, ...fromProfile, profile, ...session };
- }
-
- function exactDefaultFor(key, profileName) {
- const { exact, profiles } = resolveExactBundle();
- const profile = profileName || exact.generation?.profile || detectKreaProfileName();
- const fromProfile = profiles[profile]?.[key];
- if (fromProfile != null) {
- return fromProfile;
- }
- return exact.generation?.[key];
- }
-
- function rememberSessionExact(partial) {
- if (state.restoringChat || !partial || typeof partial !== 'object') {
- return;
- }
- const keys = ['steps', 'cfg', 'sigma_shift', 'aspect', 'width', 'height', 'images', 'batch', 'seed', 'sampler', 'scheduler'];
- for (const k of keys) {
- if (partial[k] != null) {
- state.sessionExact[k] = partial[k];
- }
- }
- if (partial.images == null && partial.batch != null) {
- state.sessionExact.images = partial.batch;
- }
- }
-
- function fillEmptyParamsFromExact() {
- const defaults = mergedGenerationDefaults();
- if (isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) {
- setVal('input_steps', String(defaults.steps));
- }
- const cfgRaw = val('input_cfgscale') || val('input_cfg');
- if (isEmptyParamField(cfgRaw, { treatZeroEmpty: true }) && defaults.cfg != null) {
- if (document.getElementById('input_cfgscale')) {
- setVal('input_cfgscale', String(defaults.cfg));
- } else if (document.getElementById('input_cfg')) {
- setVal('input_cfg', String(defaults.cfg));
- }
- }
- if (isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
- setVal('input_sigmashift', String(defaults.sigma_shift));
- }
- const wEmpty = isEmptyParamField(val('input_width'), { treatZeroEmpty: true });
- const hEmpty = isEmptyParamField(val('input_height'), { treatZeroEmpty: true });
- if ((wEmpty || hEmpty) && defaults.aspect) {
- const size = sizeFromAspect(defaults.aspect);
- if (size) {
- if (wEmpty) {
- setVal('input_width', String(size[0]));
- }
- if (hEmpty) {
- setVal('input_height', String(size[1]));
- }
- }
- } else {
- if (wEmpty && defaults.width != null) {
- setVal('input_width', String(defaults.width));
- }
- if (hEmpty && defaults.height != null) {
- setVal('input_height', String(defaults.height));
- }
- }
- const batchId = document.getElementById('input_images') ? 'input_images' : (document.getElementById('input_batchsize') ? 'input_batchsize' : null);
- if (batchId && isEmptyParamField(val(batchId), { treatZeroEmpty: true })) {
- const batch = defaults.images != null ? defaults.images : defaults.batch;
- if (batch != null) {
- setVal(batchId, String(batch));
- }
- }
- }
-
- function shouldSkipSessionRollback(key, patchValue) {
- if (state.restoringChat) {
- return false;
- }
- if (state.lastUserParamIntent) {
- return false;
- }
- if (state.sessionExact[key] == null) {
- return false;
- }
- const sessionVal = state.sessionExact[key];
- if (String(sessionVal) === String(patchValue)) {
- return false;
- }
- const exactVal = exactDefaultFor(key);
- if (exactVal == null) {
- return false;
- }
- // Model trying to restore file exact while session override differs — keep session.
- return String(patchValue) === String(exactVal);
- }
-
- function openAssistentTab() {
- const tab = document.getElementById(TAB_BUTTON_ID);
- if (tab) {
- tab.click();
- setTimeout(() => $('sa_input')?.focus(), 50);
- return true;
- }
- const pane = document.getElementById('assistent');
- if (pane && typeof bootstrap !== 'undefined' && bootstrap.Tab) {
- try {
- bootstrap.Tab.getOrCreateInstance(tab || pane).show();
- } catch (e) { /* ignore */ }
- }
- setTimeout(() => $('sa_input')?.focus(), 50);
- return !!tab;
- }
-
- function historyMessageLimit() {
- const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
- return turns * 2;
- }
-
- function flashImagePane(slotId) {
- const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
- if (!el) {
- return;
- }
- el.classList.remove('sa-flash');
- void el.offsetWidth;
- el.classList.add('sa-flash');
- }
-
- function ensureBoard() {
- if (state.slots.length) {
- return;
- }
- state.slots = [
- { id: GEN_ID, type: 'generate', label: 'Generate', src: null, attach: false },
- { id: 'ref1', type: 'ref', label: 'Ref 1', src: null, attach: true },
- ];
- state.refSeq = 1;
- state.selectedSlotId = 'ref1';
- }
-
- function slotById(id) {
- ensureBoard();
- const key = normalizeSlotId(id);
- return state.slots.find((s) => s.id === key) || null;
- }
-
- function generateSlot() {
- return slotById(GEN_ID);
- }
-
- function refSlots() {
- ensureBoard();
- return state.slots.filter((s) => s.type === 'ref');
- }
-
- function normalizeSlotId(id) {
- const raw = String(id || '').trim().toLowerCase();
- if (!raw) {
- return '';
- }
- if (raw === 'gen' || raw === 'current' || raw === 'live' || raw === 'generation') {
- return GEN_ID;
- }
- if (raw === 'selected' || raw === 'sel') {
- return state.selectedSlotId;
- }
- const m = raw.match(/^ref\s*[_-]?\s*(\d+)$/);
- if (m) {
- return `ref${m[1]}`;
- }
- return raw;
- }
-
- function selectedSlot() {
- return slotById(state.selectedSlotId) || generateSlot();
- }
-
- function selectedSrc() {
- return selectedSlot()?.src || null;
- }
-
- function syncLastImageAlias() {
- const attached = attachableSlots();
- state.lastImageDataUrl = (attached[0] || selectedSlot() || generateSlot())?.src || null;
- }
-
- function attachableSlots() {
- ensureBoard();
- return state.slots.filter((s) => s.attach && s.src);
- }
-
- /** Real board frames (not model previews). Used for has_vision_image even when JPEG is not sent. */
- function visionReadySlots() {
- ensureBoard();
- return state.slots.filter((s) => s && s.src && !looksLikeModelPreview(s.src));
- }
-
- function setSlotSrc(id, src, { select = true, attach = null, note = null, switchTab = false, allowPreview = false } = {}) {
- const slot = slotById(id);
- if (!slot) {
- return false;
- }
- const cleaned = src ? String(src).trim().split(/\s+/)[0] : null;
- if (cleaned && cleaned.startsWith('#')) {
- return false;
- }
- if (cleaned && !allowPreview && looksLikeModelPreview(cleaned)) {
- setStatus('Пропуск превью модели (нужна реальная генерация)');
- return false;
- }
- slot.src = cleaned || null;
- if (attach != null) {
- slot.attach = !!attach;
- } else if (slot.type === 'ref' && slot.src) {
- slot.attach = true;
- }
- if (select) {
- state.selectedSlotId = slot.id;
- }
- syncLastImageAlias();
- renderBoard();
- flashImagePane(slot.id);
- if (switchTab) {
- openAssistentTab();
- }
- if (note) {
- setStatus(note);
- }
- return true;
- }
-
- function addRefSlot({ src = null, select = true } = {}) {
- ensureBoard();
- if (refSlots().length >= MAX_REF_SLOTS) {
- setStatus(`Max ${MAX_REF_SLOTS} reference windows`);
- const empty = refSlots().find((s) => !s.src);
- if (empty && src) {
- return setSlotSrc(empty.id, src, { select, note: `Loaded into ${empty.label}` });
- }
- return empty || null;
- }
- state.refSeq += 1;
- const id = `ref${state.refSeq}`;
- const slot = {
- id,
- type: 'ref',
- label: `Ref ${state.refSeq}`,
- src: src || null,
- attach: !!src,
- };
- state.slots.push(slot);
- if (select) {
- state.selectedSlotId = id;
- }
- renderBoard();
- return slot;
- }
-
- function clearSlot(id, { silent = false } = {}) {
- const slot = slotById(id);
- if (!slot) {
- return;
- }
- if (slot.type === 'generate') {
- if (!silent) {
- setStatus('Generate window is live — use Snapshot gen to copy it');
- }
- return;
- }
- slot.src = null;
- slot.attach = true;
- syncLastImageAlias();
- renderBoard();
- if (!silent) {
- setStatus(`${slot.label} cleared`);
- }
- }
-
- function snapshotGenerateToRef() {
- // Never promote checkpoint/LoRA card previews into Refs.
- const src = generateSlot()?.src && !looksLikeModelPreview(generateSlot().src)
- ? generateSlot().src
- : findCurrentGenerateSrc({ allowPreview: false });
- if (!src) {
- setStatus('Нет текущего кадра Generate (превью модели не считается)');
- return false;
- }
- const empty = refSlots().find((s) => !s.src);
- let ok = false;
- if (empty) {
- ok = setSlotSrc(empty.id, src, { note: `Снимок → ${empty.label}` });
- } else {
- const created = addRefSlot({ src, select: true });
- if (created?.src) {
- setStatus(`Снимок → ${created.label}`);
- flashImagePane(created.id);
- ok = true;
- } else {
- const last = refSlots()[refSlots().length - 1];
- if (last) {
- ok = setSlotSrc(last.id, src, { note: `Снимок → ${last.label} (замена)` });
- }
- }
- }
- if (ok) {
- setBoardTab('refs');
- }
- return ok;
- }
-
- function putImageOnBoard(src, { note = null, switchTab = false, preferSelected = true } = {}) {
- if (!src) {
- return false;
- }
- ensureBoard();
- const sel = selectedSlot();
- if (preferSelected && sel && sel.type === 'ref') {
- return setSlotSrc(sel.id, src, { note: note || `Loaded into ${sel.label}`, switchTab });
- }
- const empty = refSlots().find((s) => !s.src);
- if (empty) {
- return setSlotSrc(empty.id, src, { note: note || `Loaded into ${empty.label}`, switchTab });
- }
- const created = addRefSlot({ src, select: true });
- if (created) {
- if (switchTab) {
- openAssistentTab();
- }
- if (note) {
- setStatus(note);
- }
- return true;
- }
- return false;
- }
-
- function setImageFromSrc(src, opts = {}) {
- return putImageOnBoard(src, opts);
- }
-
- function clearVisionImage(opts) {
- clearSlot(state.selectedSlotId, opts);
- }
-
- function slotCatalog() {
- ensureBoard();
- return state.slots.map((s) => ({
- id: s.id,
- type: s.type,
- label: s.label,
- has_image: !!s.src,
- attach: !!s.attach,
- selected: s.id === state.selectedSlotId,
- }));
- }
-
- function lookAtIdsFromPatch(patch) {
- if (!patch) {
- return [];
- }
- const raw = patch.look_at || patch.vision_from || patch.vision_slots;
- const list = Array.isArray(raw) ? raw : (raw ? [raw] : []);
- if (Array.isArray(patch.actions)) {
- for (const a of patch.actions.map(String)) {
- const m = a.match(/^look_at[_:]?(generate|ref\d+|selected)$/i);
- if (m) {
- list.push(m[1]);
- }
- }
- }
- return [...new Set(list.map(normalizeSlotId).filter(Boolean))];
- }
-
- function resolveSlotSrc(id) {
- if (!id) {
- return selectedSrc() || generateSlot()?.src || findCurrentGenerateSrc();
- }
- const slot = slotById(id);
- if (slot?.src) {
- return slot.src;
- }
- if (normalizeSlotId(id) === GEN_ID) {
- return findCurrentGenerateSrc();
- }
- return null;
- }
-
- function isSwarmGenerateRunning() {
- try {
- if (typeof num_live_gens === 'number' && num_live_gens > 0) {
- return true;
- }
- if (typeof num_waiting_gens === 'number' && num_waiting_gens > 0) {
- return true;
- }
- } catch (e) { /* ignore */ }
- try {
- if (typeof mainGenHandler !== 'undefined' && mainGenHandler) {
- if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) {
- return true;
- }
- }
- } catch (e) { /* ignore */ }
- const interrupt = document.getElementById('interrupt_button')
- || document.getElementById('alt_interrupt_button');
- if (interrupt && !interrupt.hidden && interrupt.offsetParent !== null) {
- return true;
- }
- const genBtn = document.getElementById('generate_button') || document.getElementById('alt_generate_button');
- if (genBtn && (genBtn.disabled || /interrupt/i.test(genBtn.textContent || ''))) {
- return true;
- }
- return false;
- }
-
- function isGenerateUnavailable() {
- if (state.generating || state.busy) {
- return true;
- }
- return isSwarmGenerateRunning();
- }
-
- function syncGenerateBusy() {
- const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
- if (overlay) {
- // If Swarm already finished but our waiter is stuck on same URL, drop the overlay
- // as soon as the live frame is on the board.
- const stuck = state.generating && !isSwarmGenerateRunning();
- overlay.hidden = (!state.generating && state.busyPhase !== 'generating') || stuck;
- }
- }
-
- 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'
- ? 'Generate
Живой просмотр текущей генерации
'
- : 'Reference
Drop · paste · Снимок gen
';
- 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 = ' ';
- el.appendChild(busy);
-
- el.addEventListener('click', () => {
- state.selectedSlotId = slot.id;
- renderBoard();
- });
- el.addEventListener('dragover', (e) => {
- e.preventDefault();
- e.stopPropagation();
- el.classList.add('sa-dragover');
- if (e.dataTransfer) {
- e.dataTransfer.dropEffect = 'copy';
- }
- });
- el.addEventListener('dragleave', () => el.classList.remove('sa-dragover'));
- el.addEventListener('drop', async (e) => {
- e.preventDefault();
- e.stopPropagation();
- el.classList.remove('sa-dragover');
- const targetId = slot.type === 'generate' ? null : slot.id;
- if (slot.type === 'generate') {
- const created = addRefSlot({ select: true });
- await handleDropDataTransfer(e.dataTransfer, created?.id);
- setBoardTab('refs');
- } else {
- await handleDropDataTransfer(e.dataTransfer, targetId);
- }
- });
- return el;
- }
-
- function syncGenerateSlot() {
- const slot = generateSlot();
- if (!slot) {
- return;
- }
- // Drop checkpoint/LoRA card previews that slipped into the Generate pane.
- if (scrubPreviewFromGenerateSlot()) {
- renderBoard();
- }
- const src = findCurrentGenerateSrc();
- if (src && src !== slot.src) {
- slot.src = src;
- const img = document.querySelector('.sa-slot-gen img');
- const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
- const frame = document.querySelector('.sa-slot-gen');
- if (img) {
- img.src = src;
- } else if (frame) {
- renderBoard();
- return;
- }
- if (empty) {
- empty.hidden = true;
- }
- frame?.classList.add('sa-has-image');
- } else if (src && slot.src === src) {
- // Same URL, possibly new bytes after overwrite — nudge once Swarm is idle.
- if (state.generating && !isSwarmGenerateRunning()) {
- const img = document.querySelector('.sa-slot-gen img');
- if (img) {
- const bump = src.includes('?') ? `${src}&sa_t=${Date.now()}` : `${src}?sa_t=${Date.now()}`;
- img.src = bump;
- }
- }
- } else if (!src && !slot.src) {
- const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
- const frame = document.querySelector('.sa-slot-gen');
- const img = document.querySelector('.sa-slot-gen img');
- if (img) {
- img.remove();
- }
- if (empty) {
- empty.hidden = false;
- }
- frame?.classList.remove('sa-has-image', 'sa-attached');
- }
- syncGenerateBusy();
- syncPatchActionAvailability();
- }
-
- function maybeWelcome() {
- if (localStorage.getItem(LS_WELCOMED) === '1') {
- return;
- }
- if (!$('sa_messages')) {
- return;
- }
- localStorage.setItem(LS_WELCOMED, '1');
- const box = $('sa_messages');
- hideChatEmpty();
- const div = document.createElement('div');
- div.className = 'sa-msg assistant sa-welcome';
- div.innerHTML = WELCOME_HTML;
- box.appendChild(div);
- scrollMessagesToBottom({ force: true });
- }
-
- function chatUid() {
- return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
- }
-
- function stripJsonFencesForHistory(content) {
- return String(content || '')
- .replace(/```(?:json)?\s*[\s\S]*?```/gi, '')
- // Drop echoed critique templates / empty patch headers so the next turn doesn't copy them.
- .replace(/###\s*Critique\b[\s\S]*?(?=###|$)/gi, '')
- .replace(/###\s*JSON\s*Patch\b[\s\S]*$/gi, '')
- .replace(/\n{3,}/g, '\n\n')
- .trim();
- }
-
- function slimHistoryMessages(list) {
- return (list || [])
- .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish)
- .slice(-MAX_CHAT_MSGS)
- .map((m) => {
- let content = String(m.content || '');
- if (m.role === 'assistant') {
- content = stripJsonFencesForHistory(content);
- }
- return {
- role: m.role,
- content: content.slice(0, 4000),
- persona: m.persona || undefined,
- pack: m.pack || undefined,
- };
- });
- }
-
- function titleFromMessages(messages) {
- const u = (messages || []).find((m) => m.role === 'user' && m.content);
- const t = String(u?.content || '').replace(/\s+/g, ' ').trim();
- return t ? t.slice(0, 52) : 'Новый чат';
- }
-
- function snapshotChatParams() {
- let loras = [];
- try {
- if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
- loras = loraHelper.selected.map((l) => ({
- name: l.name || l,
- weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
- }));
- }
- } catch (e) { /* ignore */ }
- let lastPatch = null;
- try {
- lastPatch = state.lastPatch ? JSON.parse(JSON.stringify(state.lastPatch)) : null;
- } catch (e) {
- lastPatch = null;
- }
- let sessionExact = {};
- try {
- sessionExact = state.sessionExact && typeof state.sessionExact === 'object'
- ? JSON.parse(JSON.stringify(state.sessionExact))
- : {};
- } catch (e) {
- sessionExact = {};
- }
- return {
- prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
- negative: val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '',
- width: parseInt(val('input_width') || '0', 10) || null,
- height: parseInt(val('input_height') || '0', 10) || null,
- steps: parseInt(val('input_steps') || '0', 10) || null,
- cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
- sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
- seed: val('input_seed') || null,
- sampler: val('input_sampler') || null,
- scheduler: val('input_scheduler') || null,
- batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
- loras,
- persona: $('sa_persona')?.value || 'neutral',
- pack: $('sa_pack')?.value || defaultPackId(),
- sessionExact,
- lastPatch,
- };
- }
-
- 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 }));
- saveActiveChatToDisk();
- } catch (e) {
- console.warn('Assistent: persist chats failed', e);
- try {
- // Quota fallback: keep fewer / shorter chats.
- const slim = (state.chats || [])
- .filter((c) => c && c.id && (c.id === state.activeChatId || (c.messages || []).length > 0))
- .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
- .slice(0, 12)
- .map((c) => ({
- ...c,
- messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
- ...m,
- content: String(m.content || '').slice(0, 1500),
- })),
- }));
- state.chats = slim;
- localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
- saveActiveChatToDisk();
- } catch (e2) {
- console.warn('Assistent: chats quota fallback failed', e2);
- }
- }
- }
-
- /** Mirrors the active chat onto the data volume (debounced inside SA.persist). */
- function saveActiveChatToDisk() {
- const persist = diskPersist();
- if (!persist || !state.activeChatId) {
- return;
- }
- const chat = findChat(state.activeChatId);
- if (!chat || !(chat.messages || []).length) {
- return;
- }
- persist.saveChat(chat);
- }
-
- function loadChatsStore() {
- state.chats = [];
- try {
- const raw = localStorage.getItem(LS_CHATS);
- if (raw) {
- const parsed = JSON.parse(raw);
- if (Array.isArray(parsed?.chats)) {
- state.chats = parsed.chats.filter((c) => c && c.id);
- }
- }
- } catch (e) { /* ignore */ }
- migrateLegacyHistoryIntoChats();
- }
-
- /** Disk wins over localStorage — chats follow the data volume, not the browser. */
- async function loadChatsFromDisk() {
- const persist = diskPersist();
- if (!persist) {
- return;
- }
- let chats = null;
- try {
- chats = await persist.loadChats();
- } catch (e) {
- console.warn('Assistent: disk chats failed', e);
- return;
- }
- if (!Array.isArray(chats) || !chats.length) {
- return;
- }
- state.chats = chats.filter((c) => c && c.id).slice(0, MAX_CHATS);
- try {
- localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: state.chats }));
- } catch (e) { /* quota — disk is the source of truth anyway */ }
- }
-
- function migrateLegacyHistoryIntoChats() {
- try {
- const raw = localStorage.getItem(LS_HISTORY);
- if (!raw) {
- return;
- }
- const parsed = JSON.parse(raw);
- if (!Array.isArray(parsed) || !parsed.length) {
- localStorage.removeItem(LS_HISTORY);
- return;
- }
- const messages = slimHistoryMessages(parsed);
- if (!messages.length) {
- localStorage.removeItem(LS_HISTORY);
- return;
- }
- const already = state.chats.some((c) =>
- (c.messages || []).length === messages.length
- && (c.messages[0]?.content || '') === (messages[0]?.content || ''));
- if (!already) {
- state.chats.unshift({
- id: chatUid(),
- title: titleFromMessages(messages),
- createdAt: Date.now() - 1000,
- updatedAt: Date.now() - 1000,
- messages,
- params: null,
- });
- persistChatsStore();
- }
- localStorage.removeItem(LS_HISTORY);
- } catch (e) {
- try { localStorage.removeItem(LS_HISTORY); } catch (e2) { /* ignore */ }
- }
- }
-
- function findChat(id) {
- return (state.chats || []).find((c) => c.id === id) || null;
- }
-
- function saveActiveChatToStore({ dropEmpty = false } = {}) {
- if (!state.activeChatId || state.restoringChat) {
- return;
- }
- const chat = findChat(state.activeChatId);
- if (!chat) {
- return;
- }
- chat.messages = slimHistoryMessages(state.history);
- chat.params = snapshotChatParams();
- chat.updatedAt = Date.now();
- chat.title = titleFromMessages(chat.messages);
- if (dropEmpty && !chat.messages.length) {
- state.chats = state.chats.filter((c) => c.id !== chat.id);
- if (state.activeChatId === chat.id) {
- state.activeChatId = null;
- }
- }
- persistChatsStore();
- }
-
- function resetMessagesUi(emptyHint) {
- const box = $('sa_messages');
- if (!box) {
- return;
- }
- box.innerHTML = '';
- const empty = document.createElement('div');
- empty.className = 'sa-chat-empty';
- empty.id = 'sa_chat_empty';
- empty.innerHTML = emptyHint
- || 'Новый чат
Параметры Generate остаются как сейчас.+ — ещё один чат · История — вернуться к прошлому (с его параметрами).
';
- box.appendChild(empty);
- }
-
- function renderHistoryIntoUi(messages) {
- const box = $('sa_messages');
- if (!box) {
- return;
- }
- box.innerHTML = '';
- const list = slimHistoryMessages(messages);
- if (!list.length) {
- resetMessagesUi();
- return;
- }
- for (const m of list) {
- if (m.role === 'user') {
- appendMessage('user', m.content, null, null, { historical: true });
- } else {
- appendMessage('assistant', m.content, null, null, {
- persona: m.persona ? { id: m.persona, title: m.persona } : null,
- pack: m.pack,
- historical: true,
- });
- }
- }
- }
-
- function updateSessionLabel() {
- const el = $('sa_session_label');
- if (!el) {
- return;
- }
- const chat = findChat(state.activeChatId);
- el.textContent = chat?.title || 'Новый чат';
- el.title = (chat?.title || 'Новый чат') + ' — клик: История';
- }
-
- function savedChatsCount() {
- return (state.chats || []).filter((c) => (c.messages || []).length > 0).length;
- }
-
- function syncHistoryBadge() {
- const btn = $('sa_btn_chats');
- if (!btn) {
- return;
- }
- const n = savedChatsCount();
- btn.textContent = n > 0 ? `История (${n})` : 'История';
- btn.title = n > 0
- ? `Сохранённых чатов: ${n}. Переключение восстанавливает параметры.`
- : 'История чатов (пока пусто)';
- }
-
- function formatChatWhen(ts) {
- if (!ts) {
- return '';
- }
- try {
- return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
- } catch (e) {
- return '';
- }
- }
-
- function chatMatchesQuery(chat, q) {
- if (!q) {
- return true;
- }
- const title = String(chat?.title || '').toLowerCase();
- if (title.includes(q)) {
- return true;
- }
- const msgs = chat?.messages || [];
- for (const m of msgs) {
- if (String(m?.content || '').toLowerCase().includes(q)) {
- return true;
- }
- }
- return false;
- }
-
- function renderChatsList() {
- const root = $('sa_chats_list');
- if (!root) {
- return;
- }
- root.innerHTML = '';
- syncHistoryBadge();
- const q = (state.chatsQuery || '').trim().toLowerCase();
- let chats = (state.chats || [])
- .slice()
- .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
- .filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
- if (q) {
- const local = chats.filter((c) => chatMatchesQuery(c, q));
- const seen = new Set(local.map((c) => c.id));
- const extra = (state.chatsSearchHits || []).filter((h) => h && h.id && !seen.has(h.id));
- chats = local.concat(extra);
- }
- if (!chats.length) {
- root.innerHTML = q
- ? 'Ничего не нашлось.
'
- : 'Пока пусто. Напиши что-нибудь в чат — он появится здесь. Переключение вернёт и сообщения, и параметры Generate.
';
- return;
- }
- for (const c of chats) {
- const row = document.createElement('div');
- row.className = 'sa-chat-row' + (c.id === state.activeChatId ? ' sa-chat-row-active' : '');
- row.dataset.id = c.id;
- const n = (c.messages || []).length || Number(c.messages_count) || 0;
- const bits = [];
- if (c.params?.width && c.params?.height) {
- bits.push(`${c.params.width}×${c.params.height}`);
- }
- if (c.params?.steps != null) {
- bits.push(`steps ${c.params.steps}`);
- }
- if (c.params?.cfg != null) {
- bits.push(`cfg ${c.params.cfg}`);
- }
- if (Array.isArray(c.params?.loras) && c.params.loras.length) {
- bits.push(`LoRA ${c.params.loras.length}`);
- }
- const noParams = !c.params ? ' · без снимка params' : '';
- row.innerHTML = `${escapeHtml(c.title || 'Чат')} ${escapeHtml([formatChatWhen(c.updatedAt), `${n} сообщ.`, bits.join(' · ')].filter(Boolean).join(' · ') + noParams)} × `;
- root.appendChild(row);
- }
- }
-
- function setChatsPanelOpen(open) {
- state.chatsPanelOpen = !!open;
- const panel = $('sa_chats_panel');
- const btn = $('sa_btn_chats');
- if (panel) {
- panel.hidden = !state.chatsPanelOpen;
- }
- btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
- if (state.chatsPanelOpen) {
- saveActiveChatToStore();
- const search = $('sa_chats_search');
- if (search) {
- search.value = state.chatsQuery || '';
- search.focus();
- }
- renderChatsList();
- }
- }
-
- async function startNewChat({ saveCurrent = true, force = false } = {}) {
- if (!force && (state.busy || state.generating)) {
- setStatus('Занято — дождись конца ответа или Стоп');
- return;
- }
- if (force) {
- // Drop in-flight reply so it cannot land in the new chat.
- abortInFlightWork({ status: '' });
- }
- setChatsPanelOpen(false);
- if (saveCurrent) {
- saveActiveChatToStore({ dropEmpty: true });
- }
- state.sessionExact = {};
- state.lastUserParamIntent = false;
- state.pendingSilentGen = false;
- state.lastPatch = null;
- 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 });
- let chat = findChat(id);
- if (!chat || !(chat.messages || []).length) {
- try {
- const full = await diskPersist()?.getChat?.(id);
- if (full) {
- const idx = (state.chats || []).findIndex((c) => c.id === id);
- if (idx >= 0) {
- state.chats[idx] = full;
- } else {
- state.chats.unshift(full);
- }
- chat = full;
- }
- } catch (e) {
- console.warn('Assistent: getChat failed', id, e);
- }
- }
- if (!chat) {
- setStatus('Чат не найден');
- return;
- }
- state.activeChatId = chat.id;
- state.history = slimHistoryMessages(chat.messages);
- state.critiqueHopUsed = false;
- state.visionHopUsed = false;
- state.packUserTouched = false;
- state.pendingPersonaNote = null;
- state.pendingSilentGen = false;
- state.lastUserParamIntent = false;
- if (state.streamEl) {
- try { state.streamEl.remove(); } catch (e) { /* ignore */ }
- state.streamEl = null;
- }
- renderHistoryIntoUi(state.history);
- const result = await restoreChatParams(chat.params);
- updateSessionLabel();
- syncHistoryBadge();
- renderChatsList();
- setChatsPanelOpen(false);
- setView('chat');
- if (result?.restored) {
- setStatus(`Чат «${chat.title}» · параметры восстановлены`);
- } else {
- setStatus(`Чат «${chat.title}» · снимок параметров отсутствует — Generate не менялся`);
- }
- }
-
- function deleteChat(id) {
- if (!id) {
- return;
- }
- const wasActive = id === state.activeChatId;
- state.chats = state.chats.filter((c) => c.id !== id);
- if (wasActive) {
- state.activeChatId = null;
- }
- diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
- persistChatsStore();
- syncHistoryBadge();
- if (wasActive) {
- startNewChat({ saveCurrent: false, force: true });
- } else {
- renderChatsList();
- }
- }
-
- async function initChatSessions() {
- loadChatsStore();
- await loadChatsFromDisk();
- // Always open a fresh chat on startup; past chats stay in History.
- startNewChat({ saveCurrent: false, force: true });
- syncHistoryBadge();
- renderChatsList();
- }
-
- function persistHistory() {
- if (state.restoringChat) {
- return;
- }
- if (!state.activeChatId) {
- const chat = {
- id: chatUid(),
- title: 'Новый чат',
- createdAt: Date.now(),
- updatedAt: Date.now(),
- messages: [],
- params: snapshotChatParams(),
- };
- state.chats.unshift(chat);
- state.activeChatId = chat.id;
- }
- const chat = findChat(state.activeChatId);
- if (!chat) {
- return;
- }
- chat.messages = slimHistoryMessages(state.history);
- chat.params = snapshotChatParams();
- chat.updatedAt = Date.now();
- chat.title = titleFromMessages(chat.messages);
- persistChatsStore();
- updateSessionLabel();
- syncHistoryBadge();
- }
-
- function restoreHistory() {
- // Replaced by initChatSessions — kept as no-op for safety.
- }
-
- function clearPersistedHistory() {
- if (state.activeChatId) {
- const chat = findChat(state.activeChatId);
- if (chat) {
- chat.messages = [];
- chat.title = 'Новый чат';
- chat.params = snapshotChatParams();
- chat.updatedAt = Date.now();
- }
- persistChatsStore();
- }
- updateSessionLabel();
- renderChatsList();
- }
-
- function clearChatHistory() {
- abortInFlightWork({ status: '' });
- state.history = [];
- state.critiqueHopUsed = false;
- state.visionHopUsed = false;
- state.packUserTouched = false;
- state.pendingPersonaNote = null;
- state.sessionExact = {};
- state.lastUserParamIntent = false;
- state.pendingSilentGen = false;
- state.lastPatch = null;
- syncBuildGenButton();
- clearPersistedHistory();
- resetMessagesUi('Чат очищен
Сообщения сброшены. Параметры Generate на месте. + — новый чат в Историю, История — прошлые диалоги.
');
- 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 = `${escapeHtml(item.cmd.trim())} — ${escapeHtml(item.hint)}`;
- btn.addEventListener('mousedown', (e) => {
- e.preventDefault();
- applySlashPick(item);
- });
- menu.appendChild(btn);
- });
- }
-
- function applySlashPick(item) {
- const input = $('sa_input');
- if (!input || !item) {
- return;
- }
- input.value = item.cmd;
- hideSlashMenu();
- input.focus();
- const pos = input.value.length;
- input.setSelectionRange(pos, pos);
- }
-
- function updateSlashMenuFromInput() {
- const text = $('sa_input')?.value || '';
- if (!text.startsWith('/') || text.includes('\n') || /\s/.test(text.trim().slice(1)) && !text.endsWith(' ')) {
- // show while typing command token only
- const token = text.split(/\s/)[0] || '';
- if (!token.startsWith('/') || (text.includes(' ') && !SLASH_COMMANDS.some((c) => c.cmd.startsWith(token)))) {
- if (!(token.startsWith('/') && !text.includes(' '))) {
- hideSlashMenu();
- return;
- }
- }
- }
- const token = (text.split(/\s/)[0] || '');
- if (!token.startsWith('/') || text.indexOf(' ') > 0) {
- hideSlashMenu();
- return;
- }
- renderSlashMenu(slashMatches(token));
- }
-
- function onPersonaChanged() {
- const id = $('sa_persona')?.value || 'neutral';
- state.sessionExact = {};
- state.lastUserParamIntent = false;
- saveSettings();
- loadConfig(id, (data) => {
- const title = data?.personas?.find((p) => p.id === id)?.title
- || (state.personas || []).find((p) => p.id === id)?.title
- || id;
- if (data?.personas) {
- state.personas = data.personas;
- }
- appendSystemNote(`Тон → ${title}`);
- state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
- if (data?.assistant?.default_pack && $('sa_pack') && !state.packUserTouched) {
- const packId = data.assistant.default_pack;
- if ([...($('sa_pack').options || [])].some((o) => o.value === packId)) {
- $('sa_pack').value = packId;
- }
- }
- fillEmptyParamsFromExact();
- renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
- syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
- if (state.view === 'settings') {
- if (state.settingsTab === 'user') {
- refreshUserPrefs();
- }
- if (state.settingsTab === 'craft') {
- renderMemoryList();
- }
- if (state.settingsTab === 'more') {
- fillKnobsFromConfig(data);
- }
- }
- });
- }
-
- function countPromptImages() {
- try {
- const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
- if (!box) {
- return 0;
- }
- const text = box.value || '';
- const matches = text.match(/)/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(/]*>[\s\S]*?<\/image>/gi, '[image omitted]');
- s = s.replace(/ ]*>/gi, '[image omitted]');
- if (s.length > CONTEXT_PROMPT_MAX) {
- s = s.slice(0, CONTEXT_PROMPT_MAX) + '…';
- }
- return s;
- }
-
- function collectLiveContext() {
- const inv = state.inventory || {};
- const initCtx = readInitContext();
- const ctx = {
- architecture_ok: isKreaSelected(),
- checkpoint: null,
- prompt: slimPromptForContext(val('alt_prompt_textbox') || val('input_prompt') || ''),
- negative: slimPromptForContext(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || ''),
- width: parseInt(val('input_width') || '0', 10) || null,
- height: parseInt(val('input_height') || '0', 10) || null,
- steps: parseInt(val('input_steps') || '0', 10) || null,
- cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
- sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
- seed: val('input_seed') || null,
- sampler: val('input_sampler') || val('input_samplerate') || null,
- scheduler: val('input_scheduler') || null,
- batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
- prompt_image_count: countPromptImages(),
- selected_loras: [],
- available_loras: [],
- available_checkpoints: slimInventoryCheckpoints(inv.checkpoints || [], 8),
- wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 20),
- inventory_at: inv.inventory_at || null,
- has_vision_image: visionReadySlots().length > 0,
- image_slots: slotCatalog(),
- attached_slot_ids: attachableSlots().map((s) => s.id),
- has_civitai_key: !!inv.has_civitai_key,
- auto_apply: !!$('sa_auto_apply')?.checked,
- auto_generate: !!$('sa_auto_generate')?.checked,
- persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral',
- model_cards: [],
- user_prefs_count: 0,
- ...initCtx,
- };
- {
- const slim = slimInventoryLoras(inv.loras || [], INVENTORY_PROMPT_NAMES);
- ctx.available_loras = slim;
- if ((inv.loras || []).length > slim.length) {
- ctx.available_loras_truncated = true;
- ctx.available_loras_total = (inv.loras || []).length;
- }
- }
-
- try {
- const model = resolveCurrentCheckpoint();
- if (model.name || model.architecture) {
- ctx.checkpoint = {
- name: model.name || model.title || null,
- architecture: model.architecture || model.compat_class || model.class || null,
- title: model.title || null,
- };
- }
- } catch (e) { /* ignore */ }
-
- try {
- if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
- const byName = new Map();
- for (const l of inv.loras || []) {
- if (l?.name) {
- byName.set(String(l.name).toLowerCase(), l);
- }
- }
- ctx.selected_loras = loraHelper.selected.map((l) => {
- const name = l.name || l;
- const invRow = byName.get(String(name).toLowerCase()) || {};
- const out = {
- name,
- weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[name]) || invRow.default_weight || 1,
- };
- if (invRow.trigger_phrase) {
- out.trigger_phrase = invRow.trigger_phrase;
- }
- if (Array.isArray(invRow.triggers) && invRow.triggers.length) {
- out.triggers = invRow.triggers.slice(0, 8);
- }
- if (invRow.blurb) {
- out.blurb = invRow.blurb;
- }
- return out;
- });
- // selected_loras = enabled; do not also emit enabled_loras (duplicate).
- }
- } catch (e) { /* ignore */ }
-
- // Recommendation cards: checkpoint + selected LoRAs only when they add beyond inventory.
- const cardKeys = [];
- const seenCard = new Set();
- const addKey = (kind, name) => {
- if (!kind || !name) {
- return;
- }
- const key = `${kind}:${name}`;
- if (seenCard.has(key)) {
- return;
- }
- seenCard.add(key);
- cardKeys.push({ kind, name });
- };
- if (ctx.checkpoint?.name) {
- addKey('checkpoint', ctx.checkpoint.name);
- }
- for (const l of ctx.selected_loras || []) {
- if (l?.name) {
- addKey('lora', l.name);
- }
- }
- for (const k of cardKeys) {
- const cached = state.modelCards[`${k.kind}:${k.name}`];
- if (!cached) {
- continue;
- }
- const slim = slimCardForContext(cached);
- if (!slim) {
- continue;
- }
- if (k.kind === 'lora') {
- const sel = (ctx.selected_loras || []).find(
- (l) => String(l.name || '').toLowerCase() === String(k.name).toLowerCase(),
- );
- const inventoryRich = !!(sel && (sel.triggers?.length || sel.trigger_phrase || sel.blurb));
- const cardExtra = !!(slim.when || slim.avoid || slim.prompt_hint || slim.notes);
- if (inventoryRich && !cardExtra) {
- continue;
- }
- }
- ctx.model_cards.push(slim);
- }
-
- // Fallback if inventory empty
- if (!ctx.available_loras.length) {
- try {
- const models = (typeof allModels !== 'undefined' && allModels) || (typeof model_list !== 'undefined' && model_list) || [];
- const list = Array.isArray(models) ? models : Object.values(models || {});
- for (const m of list) {
- if (!m) {
- continue;
- }
- const folder = `${m.folder || m.path || ''}`;
- const isLora = /lora/i.test(m.category || m.type || '') || (m.name && String(m.name).toLowerCase().includes('lora'));
- const inLoraFolder = /lora/i.test(folder);
- if (!(isLora || inLoraFolder)) {
- continue;
- }
- ctx.available_loras.push({
- name: m.name || m.title,
- title: m.title || m.name,
- trigger_phrase: m.trigger_phrase || m.trigger || (m.metadata && (m.metadata.trigger_phrase || m.metadata.trigger)) || null,
- architecture: m.architecture || null,
- });
- }
- if (ctx.available_loras.length > INVENTORY_PROMPT_NAMES) {
- ctx.available_loras = ctx.available_loras.slice(0, INVENTORY_PROMPT_NAMES);
- }
- } catch (e) { /* ignore */ }
- }
-
- try {
- const model = ctx.checkpoint || {};
- const blob = `${model.name || ''} ${model.title || ''}`.toLowerCase();
- const hasRaw = /\braw\b/.test(blob);
- const hasTurbo = /\bturbo\b/.test(blob);
- const profile = hasRaw && !hasTurbo ? 'raw' : 'turbo';
- ctx.krea_profile = profile;
- const defaults = mergedGenerationDefaults(profile);
- // Only send recommended_params when live UI differs from Exact-backed defaults
- // (Exact itself is already in the system prompt).
- const rec = {
- steps: defaults.steps ?? 8,
- cfg: defaults.cfg ?? 1,
- sigma_shift: defaults.sigma_shift ?? 1.15,
- };
- if (defaults.aspect) {
- rec.aspect = defaults.aspect;
- }
- const liveDiffers = (ctx.steps != null && ctx.steps !== rec.steps)
- || (ctx.cfg != null && ctx.cfg !== rec.cfg)
- || (ctx.sigma_shift != null && ctx.sigma_shift !== rec.sigma_shift);
- if (liveDiffers) {
- ctx.recommended_params = rec;
- }
- } catch (e) {
- ctx.krea_profile = 'turbo';
- }
-
- ctx.session_exact = state.sessionExact && Object.keys(state.sessionExact).length
- ? { ...state.sessionExact }
- : undefined;
- // Exact KV is already in the system prompt — do not duplicate the full blob into live context.
- if (!ctx.session_exact) {
- delete ctx.session_exact;
- }
-
- return ctx;
- }
-
- function slimCardForContext(card) {
- if (!card || typeof card !== 'object') {
- return null;
- }
- const out = {
- kind: card.kind || null,
- name: card.name || null,
- triggers: Array.isArray(card.triggers) ? card.triggers.slice(0, 8) : undefined,
- weight: card.weight != null ? card.weight : undefined,
- when: card.when ? String(card.when).slice(0, 160) : undefined,
- avoid: card.avoid ? String(card.avoid).slice(0, 120) : undefined,
- prompt_hint: card.prompt_hint ? String(card.prompt_hint).slice(0, 160) : undefined,
- notes: card.notes ? String(card.notes).slice(0, 200) : undefined,
- };
- const clean = {};
- for (const [k, v] of Object.entries(out)) {
- if (v != null && v !== '') {
- clean[k] = v;
- }
- }
- return clean;
- }
-
- function summarizeTaste() {
- const t = state.taste || {};
- if (!(t.styles?.length || t.likes?.length || t.avoid?.length || t.notes)) {
- return null;
- }
- return {
- styles: (t.styles || []).slice(0, 8),
- likes: (t.likes || []).slice(0, 10),
- avoid: (t.avoid || []).slice(0, 8),
- notes: t.notes ? String(t.notes).slice(0, 240) : undefined,
- };
- }
-
- function slimInventoryLoras(list, limit) {
- const selected = new Set();
- try {
- if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
- for (const l of loraHelper.selected) {
- selected.add(String(l.name || l || '').toLowerCase());
- }
- }
- } catch (e) { /* ignore */ }
- const namesCap = Math.min(limit || INVENTORY_PROMPT_NAMES, INVENTORY_PROMPT_NAMES);
- const richCap = Math.max(4, Math.min(INVENTORY_PROMPT_RICH, namesCap));
- const rows = (list || []).map((l) => {
- const sel = selected.has(String(l.name || '').toLowerCase());
- const hasCard = !!l.has_card;
- const krea = !!l.krea_likely;
- const blurb = l.blurb || l.usage_hint || null;
- return {
- name: l.name,
- title: l.title || l.name,
- trigger_phrase: l.trigger_phrase || null,
- triggers: Array.isArray(l.triggers) ? l.triggers.slice(0, 8) : undefined,
- architecture: l.architecture || null,
- compat_class: l.compat_class || null,
- has_card: hasCard,
- krea_likely: krea,
- blurb,
- default_weight: l.default_weight || undefined,
- tags: Array.isArray(l.tags) ? l.tags.slice(0, 6) : undefined,
- _score: (sel ? 1000 : 0) + (hasCard ? 200 : 0) + (krea ? 50 : 0) + (blurb ? 10 : 0),
- };
- });
- rows.sort((a, b) => b._score - a._score || String(a.name).localeCompare(String(b.name)));
- // Rich: enabled + top krea/card (triggers/blurb). Rest: name (+ krea_likely) only.
- let richUsed = 0;
- const out = [];
- for (const row of rows) {
- if (out.length >= namesCap) {
- break;
- }
- const sel = selected.has(String(row.name || '').toLowerCase());
- let wantRich = sel;
- if (!wantRich && richUsed < richCap && (row.krea_likely || row.has_card || row.blurb)) {
- wantRich = true;
- }
- if (wantRich) {
- const rich = { name: row.name, title: row.title };
- if (row.trigger_phrase) {
- rich.trigger_phrase = row.trigger_phrase;
- }
- if (row.triggers) {
- rich.triggers = row.triggers;
- }
- if (row.krea_likely) {
- rich.krea_likely = true;
- }
- if (row.has_card) {
- rich.has_card = true;
- }
- if (row.blurb) {
- rich.blurb = row.blurb;
- }
- if (row.default_weight) {
- rich.default_weight = row.default_weight;
- }
- if (row.architecture) {
- rich.architecture = row.architecture;
- }
- out.push(rich);
- if (!sel) {
- richUsed++;
- }
- } else {
- const nameOnly = { name: row.name };
- if (row.krea_likely) {
- nameOnly.krea_likely = true;
- }
- out.push(nameOnly);
- }
- }
- return out;
- }
-
- function slimInventoryCheckpoints(list, limit) {
- const rows = (list || []).slice();
- rows.sort((a, b) => ((b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0)) || ((b.has_card ? 1 : 0) - (a.has_card ? 1 : 0)) || String(a.name).localeCompare(String(b.name)));
- return rows.slice(0, limit || 8).map((c) => {
- const out = {
- name: c.name,
- title: c.title || c.name,
- };
- if (c.architecture) {
- out.architecture = c.architecture;
- }
- if (c.krea_likely) {
- out.krea_likely = true;
- }
- if (c.has_card) {
- out.has_card = true;
- }
- return out;
- });
- }
-
- function loadTaste() {
- try {
- const raw = localStorage.getItem(LS_TASTE);
- if (!raw) {
- return;
- }
- const parsed = JSON.parse(raw);
- if (parsed && typeof parsed === 'object') {
- state.taste = {
- styles: Array.isArray(parsed.styles) ? parsed.styles.slice(0, 12) : [],
- likes: Array.isArray(parsed.likes) ? parsed.likes.slice(0, 16) : [],
- avoid: Array.isArray(parsed.avoid) ? parsed.avoid.slice(0, 12) : [],
- notes: String(parsed.notes || '').slice(0, 400),
- updated: parsed.updated || 0,
- };
- }
- } catch (e) { /* ignore */ }
- }
-
- function saveTaste() {
- try {
- localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
- } catch (e) { /* ignore */ }
- saveTasteToServerDebounced();
- }
-
- function pushUnique(arr, value, max) {
- const v = String(value || '').trim();
- if (!v || v.length < 2) {
- return;
- }
- const lower = v.toLowerCase();
- const next = (arr || []).filter((x) => String(x).toLowerCase() !== lower);
- next.unshift(v.slice(0, 80));
- return next.slice(0, max);
- }
-
- function updateTasteFromPatch(patch, userText) {
- if (!patch) {
- return;
- }
- const taste = state.taste || { styles: [], likes: [], avoid: [], notes: '' };
- if (Array.isArray(patch.loras)) {
- for (const l of patch.loras) {
- const name = l?.name || l;
- if (name) {
- taste.likes = pushUnique(taste.likes, name, 16);
- }
- }
- }
- const aspect = patch.aspect || null;
- if (aspect) {
- taste.styles = pushUnique(taste.styles, `aspect ${aspect}`, 12);
- }
- if (patch.creativity) {
- taste.styles = pushUnique(taste.styles, `creativity:${patch.creativity}`, 12);
- }
- const ut = String(userText || '').toLowerCase();
- if (/фото|photo|photoreal|реализм|film grain/.test(ut)) {
- taste.styles = pushUnique(taste.styles, 'photoreal / film', 12);
- }
- if (/аниме|anime|illustration|иллюстр/.test(ut)) {
- taste.styles = pushUnique(taste.styles, 'illustration / anime', 12);
- }
- if (/без\s+3d|не\s+3d|no\s+3d|не\s+render/.test(ut)) {
- taste.avoid = pushUnique(taste.avoid, '3D render look', 12);
- }
- taste.updated = Date.now();
- state.taste = taste;
- saveTaste();
- syncTasteHintsToUserPrefs(taste);
- }
-
- function syncTasteHintsToUserPrefs(taste) {
- if (typeof genericRequest !== 'function' || !taste) {
- return;
- }
- const upsert = (key, text) => {
- if (!text) {
- return;
- }
- genericRequest(
- 'AssistentUpsertUserPref',
- { key, text: String(text).slice(0, 240), scope: 'global', source: 'migrated_taste', pinned: false },
- () => {},
- 0,
- () => {},
- );
- };
- if (taste.avoid?.[0]) {
- upsert('avoid_hint', `Avoid: ${taste.avoid.slice(0, 4).join('; ')}`);
- }
- if (taste.styles?.[0]) {
- upsert('style_hint', `Styles: ${taste.styles.slice(0, 4).join('; ')}`);
- }
- if (taste.likes?.[0]) {
- upsert('like_hint', `Often uses: ${taste.likes.slice(0, 4).join('; ')}`);
- }
- }
-
- // Fallback key list — only used if assistent.patch.js failed to load.
- const FALLBACK_PATCH_KEYS = [
- 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
- 'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise',
- 'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack',
- 'memories', 'user_prefs',
- ];
-
- function isPatchObject(obj) {
- if (window.SA && typeof SA.isPatchObject === 'function') {
- return SA.isPatchObject(obj);
- }
- if (!obj || typeof obj !== 'object') {
- return false;
- }
- if (isCardObject(obj)) {
- return false;
- }
- return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
- }
-
- function isCardObject(obj) {
- if (window.SA && typeof SA.isCardObject === 'function') {
- return SA.isCardObject(obj);
- }
- if (!obj || typeof obj !== 'object') {
- return false;
- }
- // Prefer card shape over gen patch when both could match.
- const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
- const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height
- || obj.steps || obj.cfg || obj.aspect || obj.seed != null
- || obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
- if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
- return true;
- }
- return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
- }
-
- function extractCardJson(text) {
- if (!text) {
- return null;
- }
- const re = /```(?:json)?\s*([\s\S]*?)```/gi;
- let match;
- let last = null;
- while ((match = re.exec(text)) !== null) {
- try {
- const obj = JSON.parse(match[1].trim());
- if (isCardObject(obj)) {
- last = obj;
- }
- } catch (e) { /* ignore */ }
- }
- if (last) {
- return last;
- }
- try {
- const obj = JSON.parse(text.trim());
- return isCardObject(obj) ? obj : null;
- } catch (e) {
- return null;
- }
- }
-
- function extractPatch(text) {
- if (window.SA && typeof SA.extractPatch === 'function') {
- return SA.extractPatch(text);
- }
- if (!text) {
- return { prose: text || '', patch: null };
- }
- const re = /```(?:json)?\s*([\s\S]*?)```/gi;
- let match;
- let lastPatch = null;
- let prose = text;
- while ((match = re.exec(text)) !== null) {
- try {
- const obj = JSON.parse(match[1].trim());
- if (isPatchObject(obj)) {
- lastPatch = obj;
- prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
- }
- } catch (e) { /* not json */ }
- }
- return { prose, patch: lastPatch };
- }
-
- function normalizeAspect(raw) {
- if (raw == null) {
- return null;
- }
- let s = String(raw).trim().toLowerCase().replace(/\s+/g, '');
- if (!s) {
- return null;
- }
- if (s === 'square') {
- s = '1:1';
- } else if (s === 'portrait' || s === 'vert') {
- s = '2:3';
- } else if (s === 'landscape' || s === 'horiz') {
- s = '16:9';
- } else if (s === 'cinematic' || s === 'ultrawide') {
- s = '2.35:1';
- }
- return ASPECT_TABLE[s] ? s : null;
- }
-
- function sizeFromAspect(aspect) {
- const key = normalizeAspect(aspect);
- return key ? ASPECT_TABLE[key] : null;
- }
-
- function guessAspectFromSize(w, h) {
- const width = parseInt(w, 10);
- const height = parseInt(h, 10);
- if (!width || !height) {
- return null;
- }
- let best = null;
- let bestDist = Infinity;
- for (const [key, [aw, ah]] of Object.entries(ASPECT_TABLE)) {
- const dist = Math.abs(width / height - aw / ah) + Math.abs(width - aw) / 4000 + Math.abs(height - ah) / 4000;
- if (dist < bestDist) {
- bestDist = dist;
- best = key;
- }
- }
- return bestDist < 0.12 ? best : null;
- }
-
- function clearPromptImagesInBox() {
- const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
- if (!box) {
- return false;
- }
- const before = box.value || '';
- const next = before
- .replace(/]*>[\s\S]*?<\/image>/gi, '')
- .replace(/]*\/?>/gi, '')
- .replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '')
- .replace(/\n{3,}/g, '\n\n')
- .trim();
- if (next === before.trim()) {
- return false;
- }
- box.value = next;
- box.dispatchEvent(new Event('input', { bubbles: true }));
- box.dispatchEvent(new Event('change', { bubbles: true }));
- return true;
- }
-
- function clearPatchBlocksOnly() {
- document.querySelectorAll('#sa_messages .sa-patch, #sa_messages .sa-patch-stale, #sa_messages .sa-civitai-list').forEach((el) => el.remove());
- state.lastPatch = null;
- syncBuildGenButton();
- setStatus('Патчи убраны из чата');
- }
-
- function toggleMoreMenu(menuId, btnId) {
- const menu = $(menuId);
- const btn = $(btnId);
- if (!menu) {
- return;
- }
- const open = menu.hidden;
- document.querySelectorAll('.sa-more-menu').forEach((m) => {
- m.hidden = true;
- });
- document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
- if (open) {
- menu.hidden = false;
- btn?.setAttribute('aria-expanded', 'true');
- }
- }
-
- function closeAllMoreMenus() {
- document.querySelectorAll('.sa-more-menu').forEach((m) => {
- m.hidden = true;
- });
- document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
- }
-
- function setPackValue(packName, { flash, user } = {}) {
- const pack = $('sa_pack');
- if (!pack || !packName) {
- return false;
- }
- const resolved = PACK_ALIASES[String(packName).trim()] || String(packName).trim();
- if (![...pack.options].some((o) => o.value === resolved)) {
- return false;
- }
- if (pack.value !== resolved) {
- pack.value = resolved;
- saveSettings();
- }
- if (user) {
- state.packUserTouched = true;
- }
- if (flash) {
- pack.classList.add('sa-pack-flash');
- setTimeout(() => pack.classList.remove('sa-pack-flash'), 900);
- }
- syncModeBadge();
- return true;
- }
-
- function autoSelectPack(text) {
- if (state.packUserTouched) {
- return null;
- }
- // Комбайн «Обычный» сам выбирает поведение — не переключаем pack.
- const cur = $('sa_pack')?.value || defaultPackId();
- if (cur === 'ordinary') {
- return null;
- }
- const t = String(text || '').toLowerCase();
- if (!t.trim()) {
- return null;
- }
- // Param / aspect asks must leave a stuck critique_image pack from auto-critique.
- if (userTextMentionsParams(t) || parseAspectFromUserText(t)) {
- return cur === 'critique_image' || cur === 'describe_ref' ? 'ordinary' : 'form_params';
- }
- if (cyrTokenRe('поправь|исправь|перепиши|улучши').test(t)
- || /\b(fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
- return 'write_prompt';
- }
- if (cyrTokenRe('опиши\\s+(реф|изображ[а-яё]*|этот|эту|картинк[а-яё]*|референс)').test(t)
- || /\b(prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t)
- || /опиши\s+(этот|эту|картинк|референс)/i.test(t)) {
- return 'describe_ref';
- }
- // Bare «посмотри/смотри» is casual chat — only critique when aimed at a result/frame.
- if (/\b(critique|criticize)\b/i.test(t)
- || cyrTokenRe('критик[а-яё]*|что\\s+не\\s+так|разбери').test(t)
- || /(?:^|[^а-яёa-z0-9_])(посмотри|смотри)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген)/i.test(t)) {
- return 'critique_image';
- }
- if (/\b(inpaint|mask|img2img)\b/i.test(t)
- || cyrTokenRe('замажь|закрась|руки|лицо|маск[а-яё]*').test(t)
- || /init\s*image/i.test(t)) {
- return 'inpaint_edit';
- }
- // Any non-critique follow-up after auto-critique should leave critique mode.
- if (cur === 'critique_image') {
- return 'ordinary';
- }
- if (/\b(moodboard|compose|scene)\b/i.test(t)
- || cyrTokenRe('сцен[а-яё]*|атмосфер[а-яё]*|мизансцен[а-яё]*').test(t)) {
- return 'compose_scene';
- }
- return 'write_prompt';
- }
-
- function restoreDefaultPackAfterHop() {
- if (state.packUserTouched) {
- return;
- }
- const cur = $('sa_pack')?.value || '';
- if (cur === 'critique_image' || cur === 'describe_ref') {
- setPackValue(defaultPackId(), { flash: true });
- }
- }
-
- function patchHasGenTrigger(patch) {
- if (!patch) {
- return false;
- }
- if (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')) {
- return true;
- }
- return (
- patch.prompt != null ||
- patch.loras ||
- patch.width != null ||
- patch.height != null ||
- patch.aspect != null ||
- patch.steps != null ||
- patch.cfg != null ||
- patch.seed != null ||
- patch.sigma_shift != null ||
- patch.images != null ||
- patch.batch != null ||
- patch.vary === true ||
- patch.use_init_image ||
- patch.clear_init_image ||
- patch.init_creativity != null ||
- patch.denoise != null ||
- patch.use_mask_image ||
- patch.clear_mask_image ||
- patch.clear_prompt_images
- );
- }
-
- async function applyPatch(patch, which) {
- if (!patch) {
- return;
- }
- const doPrompt = !which || which === 'all' || which === 'prompt';
- const doLoras = !which || which === 'all' || which === 'loras';
- const doParams = !which || which === 'all' || which === 'size' || which === 'params';
- const doInit = !which || which === 'all' || which === 'params' || which === 'init';
-
- if (patch.pack) {
- setPackValue(patch.pack, { flash: true });
- }
-
- if (doPrompt && patch.clear_prompt_images) {
- clearPromptImagesInBox();
- }
-
- if (doPrompt && patch.prompt != null) {
- const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
- if (box) {
- box.value = patch.prompt;
- box.dispatchEvent(new Event('input', { bubbles: true }));
- box.dispatchEvent(new Event('change', { bubbles: true }));
- }
- if (patch.negative != null) {
- setVal('input_negativeprompt', patch.negative);
- }
- if (Array.isArray(patch.loras)) {
- for (const l of patch.loras) {
- const triggers = l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []);
- for (const t of triggers) {
- if (t && box && box.value && !box.value.includes(t)) {
- box.value = `${box.value.trim()}, ${t}`;
- box.dispatchEvent(new Event('input', { bubbles: true }));
- }
- }
- }
- }
- }
-
- if (doLoras && Array.isArray(patch.loras) && typeof loraHelper !== 'undefined' && loraHelper) {
- try {
- if (typeof loraHelper.clearLoras === 'function') {
- loraHelper.clearLoras();
- }
- } catch (e) { /* ignore */ }
- for (const l of patch.loras) {
- const name = l.name;
- if (!name) {
- continue;
- }
- try {
- if (typeof loraHelper.selectLora === 'function') {
- loraHelper.selectLora(name);
- }
- if (loraHelper.loraWeightPref && l.weight != null) {
- loraHelper.loraWeightPref[name] = l.weight;
- }
- } catch (e) {
- console.warn('Assistent: selectLora failed', name, e);
- }
- }
- try {
- if (typeof loraHelper.rebuildUI === 'function') {
- loraHelper.rebuildUI();
- }
- } catch (e) { /* ignore */ }
- }
-
- if (doParams) {
- const defaults = mergedGenerationDefaults();
- const capture = state.lastUserParamIntent;
- const aspectSize = sizeFromAspect(patch.aspect);
- if (patch.aspect != null && !shouldSkipSessionRollback('aspect', patch.aspect)) {
- if (aspectSize) {
- setVal('input_width', String(aspectSize[0]));
- setVal('input_height', String(aspectSize[1]));
- }
- if (capture) {
- rememberSessionExact({ aspect: patch.aspect });
- }
- } else if (patch.aspect == null && isEmptyParamField(val('input_width'), { treatZeroEmpty: true })
- && isEmptyParamField(val('input_height'), { treatZeroEmpty: true }) && defaults.aspect) {
- const fill = sizeFromAspect(defaults.aspect);
- if (fill) {
- setVal('input_width', String(fill[0]));
- setVal('input_height', String(fill[1]));
- }
- } else {
- if (patch.width != null && !shouldSkipSessionRollback('width', patch.width)) {
- setVal('input_width', String(patch.width));
- if (capture) {
- rememberSessionExact({ width: patch.width });
- }
- } else if (patch.width == null && isEmptyParamField(val('input_width'), { treatZeroEmpty: true }) && defaults.width != null) {
- setVal('input_width', String(defaults.width));
- }
- if (patch.height != null && !shouldSkipSessionRollback('height', patch.height)) {
- setVal('input_height', String(patch.height));
- if (capture) {
- rememberSessionExact({ height: patch.height });
- }
- } else if (patch.height == null && isEmptyParamField(val('input_height'), { treatZeroEmpty: true }) && defaults.height != null) {
- setVal('input_height', String(defaults.height));
- }
- }
- if (patch.steps != null && !shouldSkipSessionRollback('steps', patch.steps)) {
- setVal('input_steps', String(patch.steps));
- if (capture) {
- rememberSessionExact({ steps: patch.steps });
- }
- } else if (patch.steps == null && isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) {
- setVal('input_steps', String(defaults.steps));
- }
- if (patch.cfg != null && !shouldSkipSessionRollback('cfg', patch.cfg)) {
- if (document.getElementById('input_cfgscale')) {
- setVal('input_cfgscale', String(patch.cfg));
- } else {
- setVal('input_cfg', String(patch.cfg));
- }
- if (capture) {
- rememberSessionExact({ cfg: patch.cfg });
- }
- } else if (patch.cfg == null) {
- const cfgRaw = val('input_cfgscale') || val('input_cfg');
- if (isEmptyParamField(cfgRaw, { treatZeroEmpty: true }) && defaults.cfg != null) {
- if (document.getElementById('input_cfgscale')) {
- setVal('input_cfgscale', String(defaults.cfg));
- } else if (document.getElementById('input_cfg')) {
- setVal('input_cfg', String(defaults.cfg));
- }
- }
- }
- if (patch.vary === true) {
- setVal('input_seed', '-1');
- } else if (patch.lock_seed === true) {
- const cur = val('input_seed');
- if (cur && String(cur) !== '-1') {
- setVal('input_seed', cur);
- }
- } else if (patch.seed != null && !shouldSkipSessionRollback('seed', patch.seed)) {
- setVal('input_seed', String(patch.seed));
- if (capture) {
- rememberSessionExact({ seed: patch.seed });
- }
- }
- if (patch.sigma_shift != null && !shouldSkipSessionRollback('sigma_shift', patch.sigma_shift)) {
- setVal('input_sigmashift', String(patch.sigma_shift));
- if (capture) {
- rememberSessionExact({ sigma_shift: patch.sigma_shift });
- }
- } else if (patch.sigma_shift == null && isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
- setVal('input_sigmashift', String(defaults.sigma_shift));
- }
- if (patch.sampler != null) {
- if (document.getElementById('input_sampler')) {
- setVal('input_sampler', String(patch.sampler));
- }
- if (capture) {
- rememberSessionExact({ sampler: patch.sampler });
- }
- }
- if (patch.scheduler != null && document.getElementById('input_scheduler')) {
- setVal('input_scheduler', String(patch.scheduler));
- if (capture) {
- rememberSessionExact({ scheduler: patch.scheduler });
- }
- }
- const batch = patch.images != null ? patch.images : patch.batch;
- if (batch != null && !shouldSkipSessionRollback('images', batch)) {
- if (document.getElementById('input_images')) {
- setVal('input_images', String(batch));
- } else if (document.getElementById('input_batchsize')) {
- setVal('input_batchsize', String(batch));
- }
- if (capture) {
- rememberSessionExact({ images: batch });
- }
- } else if (batch == null) {
- const batchId = document.getElementById('input_images') ? 'input_images' : (document.getElementById('input_batchsize') ? 'input_batchsize' : null);
- const defBatch = defaults.images != null ? defaults.images : defaults.batch;
- if (batchId && isEmptyParamField(val(batchId), { treatZeroEmpty: true }) && defBatch != null) {
- setVal(batchId, String(defBatch));
- }
- }
- }
-
- if (doInit) {
- const creativity = patch.init_creativity != null ? patch.init_creativity : patch.denoise;
- if (creativity != null && document.getElementById('input_initimagecreativity')) {
- setVal('input_initimagecreativity', String(creativity));
- openInitImageGroup();
- }
- if (patch.mask_blur != null && document.getElementById('input_maskblur')) {
- setVal('input_maskblur', String(patch.mask_blur));
- }
- if (patch.mask_grow != null) {
- if (document.getElementById('input_maskgrow')) {
- setVal('input_maskgrow', String(patch.mask_grow));
- } else if (document.getElementById('input_maskshrinkgrow')) {
- setVal('input_maskshrinkgrow', String(patch.mask_grow));
- }
- }
- if (patch.clear_init_image || patch.clear_mask_image) {
- if (patch.clear_init_image) {
- clearFileParam('input_initimage');
- }
- if (patch.clear_mask_image) {
- clearFileParam('input_maskimage');
- }
- if (patch.clear_init_image && patch.clear_mask_image) {
- const toggler = document.getElementById('input_group_content_initimage_toggle');
- if (toggler) {
- toggler.checked = false;
- triggerChangeForEl(toggler);
- }
- }
- }
- if (patch.select_slot) {
- const id = normalizeSlotId(patch.select_slot);
- if (slotById(id)) {
- state.selectedSlotId = id;
- renderBoard();
- }
- }
- if (patch.snapshot_generate) {
- snapshotGenerateToRef();
- }
- const initId = patch.slot_to_init || (patch.use_init_image || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_init')) ? state.selectedSlotId : null);
- const maskId = patch.slot_to_mask || null;
- const src = resolveSlotSrc(patch.slot_to_init) || selectedSrc() || findCurrentGenerateSrc();
- const wantInit = patch.use_init_image === true
- || !!patch.slot_to_init
- || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_init'));
- const wantMask = patch.use_mask_image === true
- || !!patch.slot_to_mask
- || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_mask'));
- if (wantInit) {
- const initSrc = resolveSlotSrc(initId) || src;
- if (initSrc) {
- await setInitFromSrc(initSrc);
- } else {
- setStatus('No image for Init — drop a ref or wait for Generate');
- }
- }
- if (wantMask) {
- const maskSrc = resolveSlotSrc(maskId) || src;
- if (maskSrc) {
- await setMaskFromSrc(maskSrc);
- } else {
- setStatus('No image for Mask — drop a mask (white=edit) first');
- }
- }
- if (patch.slot_to_prompt_image) {
- setStatus('Prompt Images: drop the ref into the Swarm prompt box (no auto helper yet)');
- }
- }
-
- // Persona Exact controls (model or user patch). Ignore default-echo inside Generate patches.
- if (patch.controls && typeof patch.controls === 'object' && !Array.isArray(patch.controls)) {
- const schema = state.config?.controls || {};
- const filtered = filterControlPatch(patch.controls, patch);
- if (Object.keys(filtered).length) {
- const next = { ...(state.config?.control_values || state.exact?.controls || {}), ...filtered };
- savePersonaControls(filtered);
- renderPersonaControls(schema, next);
- }
- }
-
- const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
- const wantSwitch = acts.includes('persona_switch')
- || (patch.persona && typeof patch.persona === 'string')
- || patch._persona_cloned
- || patch._persona_written;
- if (wantSwitch) {
- const newId = String(patch.persona || patch._persona_cloned || patch._persona_written || '').trim();
- if (newId && AssistentConfigSafeIdClient(newId)) {
- await refreshPersonasAndSwitch(newId);
- } else if (acts.includes('persona_clone') || acts.includes('persona_write') || patch.persona_clone) {
- await refreshPersonasAndSwitch(null);
- }
- }
-
- syncChipHighlight();
- syncLiveParamsBar();
- syncBuildGenButton();
- if (state.activeChatId && !state.restoringChat) {
- const chat = findChat(state.activeChatId);
- if (chat) {
- chat.params = snapshotChatParams();
- chat.updatedAt = Date.now();
- persistChatsStore();
- }
- }
- if (!state.restoringChat) {
- setStatus(patch._persona_error ? `Persona: ${patch._persona_error}` : 'Applied patch');
- }
- }
-
- function AssistentConfigSafeIdClient(id) {
- return /^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/.test(String(id || ''));
- }
-
- async function refreshPersonasAndSwitch(preferId) {
- await new Promise((resolve) => {
- genericRequest(
- 'AssistentListPersonas',
- {},
- async (data) => {
- if (Array.isArray(data?.personas)) {
- state.personas = data.personas.map((p) => ({
- id: p.id,
- title: p.title,
- accent: p.accent,
- source: p.source,
- }));
- renderPersonaOptions(state.personas, preferId || $('sa_persona')?.value);
- }
- if (preferId && $('sa_persona')) {
- if ([...$('sa_persona').options].some((o) => o.value === preferId)) {
- $('sa_persona').value = preferId;
- await applyPersonaForChat(preferId, { quiet: true });
- }
- } else {
- loadConfig($('sa_persona')?.value, () => resolve());
- return;
- }
- resolve();
- },
- 0,
- () => resolve(),
- );
- });
- }
-
- function triggerGenerate() {
- try {
- if (typeof mainGenHandler !== 'undefined' && mainGenHandler && typeof mainGenHandler.doGenerate === 'function') {
- mainGenHandler.doGenerate();
- return true;
- }
- } catch (e) {
- console.warn('Assistent: doGenerate failed', e);
- }
- const btn =
- document.getElementById('generate_button') ||
- document.getElementById('alt_generate_button') ||
- document.querySelector('button.generate-button') ||
- document.querySelector('#generate_button, button[id*="generate"]');
- if (btn) {
- btn.click();
- return true;
- }
- return false;
- }
-
- function shouldParkLlmBeforeGen() {
- return !!$('sa_park_llm')?.checked;
- }
-
- /** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */
- function parkLlm() {
- return new Promise((resolve) => {
- const model = $('sa_model')?.value;
- if (!shouldParkLlmBeforeGen() || !model || state.llmParked || typeof genericRequest !== 'function') {
- resolve(false);
- return;
- }
- const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
- let settled = false;
- const finish = (ok) => {
- if (settled) {
- return;
- }
- settled = true;
- if (ok) {
- state.llmParked = true;
- state.expectColdLoad = true;
- }
- resolve(!!ok);
- };
- setTimeout(() => finish(false), 8000);
- genericRequest('AssistentParkLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
- });
- }
-
- /** Re-load chat model into VRAM. force=true after Generate even without park — Krea often evicts Ollama. */
- function warmLlm({ force = false } = {}) {
- return new Promise((resolve) => {
- const model = $('sa_model')?.value;
- if (!model || typeof genericRequest !== 'function') {
- resolve(false);
- return;
- }
- if (!force && !state.llmParked) {
- resolve(false);
- return;
- }
- const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
- let settled = false;
- const finish = (ok) => {
- if (settled) {
- return;
- }
- settled = true;
- state.llmParked = false;
- if (ok) {
- state.expectColdLoad = false;
- }
- resolve(!!ok);
- };
- // VL cold-load can exceed a minute — don't time out the flag early.
- setTimeout(() => finish(false), 180000);
- genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
- });
- }
-
- function cancelWaitForNewImage() {
- if (state.waitImageTimer) {
- clearInterval(state.waitImageTimer);
- state.waitImageTimer = null;
- }
- }
-
- function bumpChatEpoch() {
- state.chatEpoch = (state.chatEpoch || 0) + 1;
- return state.chatEpoch;
- }
-
- function clearInFlightUi({ status } = {}) {
- state.busy = false;
- state.generating = false;
- state.pendingSilentGen = false;
- if (state.streamEl) {
- try { state.streamEl.remove(); } catch (e) { /* ignore */ }
- state.streamEl = null;
- state.streamMeta = null;
- }
- setInterruptVisible(false);
- syncGenerateBusy();
- syncPatchActionAvailability();
- if (status != null) {
- stopBusyUi(status);
- } else {
- stopBusyUi('');
- }
- }
-
- /** Invalidate in-flight Assistent WS/wait; optionally also interrupt Swarm Generate. */
- function abortInFlightWork({ status, interruptSwarm = false } = {}) {
- bumpChatEpoch();
- cancelWaitForNewImage();
- if (interruptSwarm) {
- try {
- if (typeof doInterrupt === 'function') {
- doInterrupt(false);
- } else if (typeof genericRequest === 'function') {
- genericRequest('InterruptAll', { other_sessions: false }, () => {}, 0, () => {});
- }
- } catch (e) { /* ignore */ }
- }
- clearInFlightUi({ status: status != null ? status : '' });
- }
-
- function doInterruptNow() {
- bumpChatEpoch();
- cancelWaitForNewImage();
- try {
- if (typeof doInterrupt === 'function') {
- doInterrupt(false);
- return;
- }
- } catch (e) { /* ignore */ }
- if (typeof genericRequest === 'function') {
- genericRequest('InterruptAll', { other_sessions: false }, () => {}, 0, () => {});
- }
- }
-
- function waitForNewImage(prevSrc, timeoutMs = 180000) {
- cancelWaitForNewImage();
- const epoch = state.chatEpoch;
- const prev = String(prevSrc || '');
- return new Promise((resolve) => {
- const start = Date.now();
- let sawRunning = false;
- let idleTicks = 0;
- let candidate = null;
- state.waitImageTimer = setInterval(() => {
- if (epoch !== state.chatEpoch) {
- cancelWaitForNewImage();
- resolve(null);
- return;
- }
- const running = isSwarmGenerateRunning();
- if (running) {
- sawRunning = true;
- idleTicks = 0;
- } else if (sawRunning) {
- idleTicks += 1;
- }
- const raw = findCurrentGenerateSrc();
- const src = raw && !looksLikeModelPreview(raw) ? raw : null;
- if (src && src !== prev) {
- candidate = src;
- }
- // Primary: Swarm finished after we saw it run — accept current/changed frame
- // even when ViewImage URL was reused (same string, new bytes).
- if (sawRunning && !running && idleTicks >= 2) {
- cancelWaitForNewImage();
- resolve(candidate || src || null);
- return;
- }
- // Missed the running flag (very fast Turbo): URL changed and Swarm is idle.
- if (candidate && !running && Date.now() - start > 500) {
- cancelWaitForNewImage();
- resolve(candidate);
- return;
- }
- if (Date.now() - start > timeoutMs) {
- cancelWaitForNewImage();
- resolve(candidate || src || null);
- }
- }, 400);
- });
- }
-
- async function runGenerateFromPatch(patch, opts = {}) {
- const force = !!opts.force;
- if ((!force && !$('sa_auto_generate')?.checked) || !patchHasGenTrigger(patch)) {
- return null;
- }
- const prev = findCurrentGenerateSrc();
- if (shouldParkLlmBeforeGen()) {
- startBusyUi('parking');
- setStatus('Освобождаю VRAM…');
- await parkLlm();
- }
- 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);
- // Krea Generate almost always evicts the VL chat weights from VRAM — even when Park LLM is off.
- state.expectColdLoad = true;
- const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
- const willAutoCritique = !!$('sa_auto_critique')?.checked;
- // Auto-critique's own chat request will cold-load; otherwise preload before the user types.
- if (state.view === 'chat' && paneVisible && !willAutoCritique) {
- startBusyUi('warming');
- setStatus('Возвращаю LLM в GPU…');
- await warmLlm({ force: true });
- }
- 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;
- }
-
- /** Resolves the freshest real Generate frame — never a model preview. */
- async function resolveFinishedGenerateSrc(hint, { settleMs = 20000 } = {}) {
- scrubPreviewFromGenerateSlot();
- let src = hint && !looksLikeModelPreview(hint) ? hint : null;
- if (!src) {
- src = findCurrentGenerateSrc();
- }
- // Batch still running: the last frame is not the final one yet.
- if (isGenerateUnavailable()) {
- const settled = await waitForNewImage(src, settleMs);
- if (settled) {
- src = settled;
- }
- }
- return src && !looksLikeModelPreview(src) ? src : null;
- }
-
- async function maybeAutoCritique(imageSrc) {
- if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed) {
- return;
- }
- const src = await resolveFinishedGenerateSrc(imageSrc);
- if (!src) {
- setStatus('Авто-критика пропущена — нет готового кадра Generate');
- return;
- }
- state.critiqueHopUsed = true;
- setPackValue('critique_image', { flash: true });
- if ($('sa_input')) {
- $('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
- }
- const gen = generateSlot();
- if (gen) {
- gen.attach = true;
- gen.src = src;
- renderBoard();
- }
- setStatus('Auto-critique…');
- await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
- restoreDefaultPackAfterHop();
- }
-
- /** After Generate: send look_at with JPEG when sa_auto_vision is on (skipped if auto-critique already attaches vision). */
- async function maybeAutoVisionLook(imageSrc) {
- if (!wantsAutoVision() || $('sa_auto_critique')?.checked || state.visionHopUsed || state.busy) {
- return;
- }
- const src = await resolveFinishedGenerateSrc(imageSrc);
- if (!src) {
- return;
- }
- const gen = generateSlot();
- if (gen) {
- gen.src = src;
- gen.attach = true;
- renderBoard();
- }
- state.visionHopUsed = true;
- setPackValue('critique_image', { flash: true });
- if ($('sa_input')) {
- $('sa_input').value = 'Look at the Generate result and briefly say what worked and what to fix next.';
- }
- setStatus('Auto look_at…');
- await sendChat({ fromVisionHop: true, forceSlotIds: [GEN_ID], skipAutoPack: true });
- restoreDefaultPackAfterHop();
- }
-
- /** Board action: attach the finished Generate frame and ask for a verdict. */
- async function askLookAtResult() {
- if (state.busy || state.generating) {
- setStatus('Занято — дождись конца ответа или Стоп');
- return;
- }
- if (!updateGate()) {
- setStatus('Выбери модель Krea 2');
- return;
- }
- const src = await resolveFinishedGenerateSrc(generateSlot()?.src, { settleMs: 8000 });
- if (!src) {
- setStatus('Нет готового кадра Generate — сначала сгенерируй');
- return;
- }
- const gen = generateSlot();
- if (gen) {
- gen.src = src;
- gen.attach = true;
- }
- setBoardTab('generate');
- renderBoard();
- setView('chat');
- setPackValue('critique_image', { flash: true });
- if ($('sa_input')) {
- $('sa_input').value = 'Посмотри результат: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.';
- }
- await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true });
- }
-
- function currentPersonaInfo() {
- const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
- const known = (state.personas || []).find((p) => p && p.id === id);
- return {
- id,
- title: (known && known.title) || ({
- neutral: 'Нейтральный',
- lewd: 'Пошляк',
- aggressive: 'Агрессивный',
- }[id] || id),
- };
- }
-
- function mountAssistantMeta(div, meta = {}) {
- if (!div || div.querySelector('.sa-msg-meta')) {
- return;
- }
- const persona = meta.persona || currentPersonaInfo();
- const pack = meta.pack || $('sa_pack')?.value || '';
- div.dataset.persona = persona.id || 'neutral';
- if (pack) {
- div.dataset.pack = pack;
- }
- const row = document.createElement('div');
- row.className = 'sa-msg-meta';
- const chip = document.createElement('span');
- chip.className = `sa-persona-mark sa-persona-${persona.id || 'neutral'}`;
- chip.textContent = persona.title || persona.id;
- chip.title = `Характер: ${persona.title || persona.id}${pack ? ` · режим ${pack}` : ''}`;
- row.appendChild(chip);
- if (pack && pack !== 'ordinary' && pack !== 'write_prompt') {
- const packEl = document.createElement('span');
- packEl.className = 'sa-pack-mark';
- packEl.textContent = pack.replace(/_/g, ' ');
- packEl.title = `Режим: ${pack}`;
- row.appendChild(packEl);
- }
- div.insertBefore(row, div.firstChild);
- }
-
- function appendMessage(role, text, patch, civitaiResults, meta) {
- const box = $('sa_messages');
- if (!box) {
- return null;
- }
- hideChatEmpty();
- const div = document.createElement('div');
- div.className = `sa-msg ${role}`;
- if (role === 'assistant') {
- mountAssistantMeta(div, meta);
- }
- const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null };
- const finalPatch = patch || extracted;
- if (role === 'assistant') {
- setAssistantBody(div, prose || text || '');
- } else {
- div.textContent = prose || text || '';
- }
- // Historical replay: show prose only — no Apply strip (lastPatch comes from chat.params).
- if (finalPatch && !(meta && meta.historical)) {
- const silent = !!(meta && meta.silentPatch);
- mountPatchBlock(div, finalPatch, { silent });
- }
- if (civitaiResults && civitaiResults.length) {
- div.appendChild(buildCivitaiCards(civitaiResults));
- }
- box.appendChild(div);
- scrollMessagesToBottom({ force: true });
- return div;
- }
-
- function beginStreamMessage(meta) {
- const box = $('sa_messages');
- if (!box) {
- return null;
- }
- hideChatEmpty();
- const div = document.createElement('div');
- div.className = 'sa-msg assistant sa-streaming sa-typing';
- mountAssistantMeta(div, meta);
- const body = document.createElement('div');
- body.className = 'sa-msg-body';
- body.innerHTML = 'Waiting for the model… ';
- div.appendChild(body);
- box.appendChild(div);
- scrollMessagesToBottom({ force: true });
- state.streamEl = div;
- state.streamMeta = meta || null;
- state.streamFenceDone = false;
- return div;
- }
-
- function streamHasClosedPatchFence(text) {
- const t = String(text || '');
- if (!/```[\s\S]*```/.test(t)) {
- return false;
- }
- const { patch } = extractPatch(t);
- return !!patch;
- }
-
- function trimToClosedPatchFence(text) {
- const t = String(text || '');
- const re = /```(?:json)?\s*([\s\S]*?)```/gi;
- let match;
- let lastEnd = -1;
- while ((match = re.exec(t)) !== null) {
- try {
- const obj = JSON.parse(match[1].trim());
- if (isPatchObject(obj) || isCardObject(obj)) {
- lastEnd = match.index + match[0].length;
- }
- } catch (e) { /* ignore */ }
- }
- return lastEnd > 0 ? t.slice(0, lastEnd).trimEnd() : t;
- }
-
- function appendStreamDelta(delta) {
- if (state.streamFenceDone) {
- return;
- }
- if (!state.streamEl) {
- beginStreamMessage(state.streamMeta || undefined);
- }
- if (state.streamEl) {
- if (state.streamEl.classList.contains('sa-typing')) {
- state.streamEl.classList.remove('sa-typing');
- state.streamText = '';
- setAssistantBody(state.streamEl, '', { live: true });
- }
- state.gotDelta = true;
- state.expectColdLoad = false;
- if (state.busyPhase !== 'refining') {
- setBusyPhase('streaming');
- }
- state.streamText = (state.streamText || '') + (delta || '');
- if (streamHasClosedPatchFence(state.streamText)) {
- state.streamText = trimToClosedPatchFence(state.streamText);
- state.streamFenceDone = true;
- }
- setAssistantBody(state.streamEl, state.streamText, { live: true });
- scrollMessagesToBottom();
- }
- }
-
- function finalizeStreamMessage(fullReply, civitaiResults) {
- const el = state.streamEl;
- const meta = state.streamMeta;
- state.streamEl = null;
- state.streamMeta = null;
- state.streamText = '';
- state.streamFenceDone = false;
- if (!el) {
- appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined);
- return;
- }
- el.classList.remove('sa-streaming', 'sa-typing');
- mountAssistantMeta(el, meta || undefined);
- const card = extractCardJson(fullReply);
- const { prose, patch } = extractPatch(fullReply);
- setAssistantBody(el, prose || fullReply || '');
- el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove());
- if (patch && !isCardObject(patch) && !(card && !patch.prompt && !patch.actions && !patch.loras)) {
- const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
- mountPatchBlock(el, patch, { silent });
- } else if (card) {
- const wrap = document.createElement('div');
- wrap.className = 'sa-patch sa-card-json-preview';
- const pre = document.createElement('pre');
- pre.textContent = JSON.stringify(card, null, 2);
- wrap.appendChild(pre);
- el.appendChild(wrap);
- }
- if (civitaiResults && civitaiResults.length) {
- el.appendChild(buildCivitaiCards(civitaiResults));
- }
- scrollMessagesToBottom();
- }
-
- function buildCivitaiCards(results) {
- const list = document.createElement('div');
- list.className = 'sa-civitai-list';
- for (const r of results) {
- const card = document.createElement('div');
- card.className = 'sa-civitai-card' + (r.already_installed ? ' sa-installed' : '');
- const title = document.createElement('div');
- title.className = 'sa-civitai-title';
- title.textContent = r.name || r.file_name || 'LoRA';
- card.appendChild(title);
- const meta = document.createElement('div');
- meta.className = 'sa-civitai-meta';
- const bits = [
- r.base_model || '?',
- r.krea_likely ? 'Krea?' : null,
- r.already_installed ? 'already installed' : null,
- (r.triggers || []).slice(0, 3).join(', ') || null,
- ].filter(Boolean);
- meta.textContent = bits.join(' · ');
- card.appendChild(meta);
- const actions = document.createElement('div');
- actions.className = 'sa-civitai-actions';
- if (r.already_installed) {
- const note = document.createElement('span');
- note.textContent = 'Уже установлена';
- actions.appendChild(note);
- } else if (r.download_url) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'basic-button sa-primary';
- btn.textContent = 'Подтвердить скачивание';
- btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
- actions.appendChild(btn);
- } else {
- const note = document.createElement('span');
- note.textContent = 'Нет URL скачивания';
- actions.appendChild(note);
- }
- card.appendChild(actions);
- list.appendChild(card);
- }
- return list;
- }
-
- function downloadCivitaiLoRA(card, btn) {
- if (!card.download_url) {
- return;
- }
- if (btn) {
- btn.disabled = true;
- btn.textContent = 'Скачиваю…';
- }
- setStatus(`Скачиваю ${card.file_name || card.name}…`);
- setInterruptVisible(true);
- const payload = {
- url: card.download_url,
- type: 'LoRA',
- name: card.file_name || card.name || 'lora',
- };
- const onDone = (ok, msg) => {
- setInterruptVisible(state.busy || state.generating);
- if (ok) {
- setStatus(`Скачано ${payload.name}`);
- if (btn) {
- btn.textContent = 'Скачано';
- }
- refreshInventory(async () => {
- await maybeWriteCardAfterDownload({
- kind: 'lora',
- name: payload.name,
- civitai: card,
- });
- }, { rescan: true });
- } else {
- setStatus(msg || 'Ошибка скачивания');
- if (btn) {
- btn.disabled = false;
- btn.textContent = 'Подтвердить скачивание';
- }
- appendMessage('error', msg || 'Ошибка скачивания');
- }
- };
- if (typeof makeWSRequest === 'function') {
- makeWSRequest(
- 'DoModelDownloadWS',
- payload,
- (data) => {
- if (data.error) {
- onDone(false, String(data.error));
- return;
- }
- if (data.success || data.overall_percent >= 1 || data.current_percent >= 1) {
- if (data.success || data.overall_percent >= 0.99) {
- // Swarm docs: download does not always refresh model list — force both.
- triggerSwarmModelRefresh(() => onDone(true));
- } else if (data.current_percent != null) {
- setStatus(`Download ${(data.current_percent * 100).toFixed(0)}%`);
- }
- }
- },
- 0,
- (err) => onDone(false, String(err || 'Download failed')),
- );
- } else {
- onDone(false, 'makeWSRequest unavailable');
- }
- }
-
- async function maybeWriteCardAfterDownload({ kind, name, civitai }) {
- const display = name || civitai?.file_name || civitai?.name || 'model';
- appendSystemNote(`Downloaded ${display}. Writing a recommendation card…`);
- setPackValue('catalog_card', { flash: true });
- const meta = {
- triggers: civitai?.triggers || [],
- base_model: civitai?.base_model,
- civitai_url: civitai?.url || civitai?.civitai_url,
- version_id: civitai?.version_id || civitai?.modelVersionId,
- name: display,
- };
- if ($('sa_input')) {
- $('sa_input').value = '';
- }
- await sendChat({
- forcedUserText: `LoRA "${display}" is now installed. Write a recommendation card (JSON) using its triggers/metadata. Then briefly suggest how to enable it in the next generate.`,
- skipSlash: true,
- skipAutoPack: true,
- fromDownload: true,
- fromCards: true,
- cardTarget: { kind: kind || 'lora', name: display, meta },
- });
- }
-
- function wantsAutoVision() {
- return !!$('sa_auto_vision')?.checked;
- }
-
- function looksLikeModelPreview(src) {
- const s = String(src || '').toLowerCase();
- if (!s) {
- return false;
- }
- // Only SwarmUI checkpoint/LoRA card routes — not bare "/models/" (matches Civitai page URLs).
- return s.includes('.preview.')
- || s.includes('placeholder')
- || s.includes('/viewspecial/')
- || s.includes('viewspecial/')
- || s.includes('/view/models/')
- || /\/view\/models\//.test(s)
- || /[?&](?:path|file)=[^&]*\.preview\./i.test(s);
- }
-
- function findCurrentGenerateSrc({ allowPreview = false } = {}) {
- let src = null;
- try {
- const cur = document.getElementById('current_image_img')
- || document.querySelector('#current_image img')
- || document.querySelector('.current-image img')
- || document.querySelector('#current_image_batch img');
- if (cur) {
- src = cur.dataset?.src || cur.getAttribute?.('data-src') || cur.src || null;
- }
- } catch (e) { /* ignore */ }
- if (!src) {
- try {
- if (typeof currentMetadataMap !== 'undefined' && currentMetadataMap && currentMetadataMap.image) {
- src = currentMetadataMap.image;
- }
- } catch (e) { /* ignore */ }
- }
- if (!src) {
- return null;
- }
- // Strip cache-busters for classification, keep original for display when accepted.
- if (!allowPreview && looksLikeModelPreview(src)) {
- return null;
- }
- return src;
- }
-
- function scrubPreviewFromGenerateSlot() {
- const slot = generateSlot();
- if (!slot?.src) {
- return false;
- }
- if (!looksLikeModelPreview(slot.src)) {
- return false;
- }
- slot.src = null;
- slot.attach = false;
- syncLastImageAlias();
- return true;
- }
-
- function refreshImagePreview() {
- scrubPreviewFromGenerateSlot();
- if (wantsAutoVision()) {
- const gen = generateSlot();
- if (gen) {
- const src = findCurrentGenerateSrc();
- if (src) {
- gen.attach = true;
- gen.src = src;
- } else {
- gen.attach = false;
- }
- renderBoard();
- }
- }
- syncGenerateSlot();
- }
-
- function fileToDataUrl(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(String(reader.result || ''));
- reader.onerror = reject;
- reader.readAsDataURL(file);
- });
- }
-
- async function acceptImageFile(file, slotId) {
- if (!file || !String(file.type || '').startsWith('image/')) {
- setStatus('Not an image file');
- return false;
- }
- const dataUrl = await fileToDataUrl(file);
- if (slotId) {
- return setSlotSrc(slotId, dataUrl, { note: `Loaded ${file.name || 'image'}` });
- }
- return putImageOnBoard(dataUrl, { note: `Loaded ${file.name || 'image'}` });
- }
-
- async function handleDropDataTransfer(dt, slotId) {
- if (!dt) {
- return false;
- }
- if (dt.files && dt.files.length) {
- for (const file of dt.files) {
- if (String(file.type || '').startsWith('image/')) {
- return acceptImageFile(file, slotId);
- }
- }
- }
- const uri = (dt.getData('text/uri-list') || dt.getData('text/plain') || '').trim();
- if (uri) {
- const first = uri.split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'));
- if (first) {
- if (slotId) {
- return setSlotSrc(slotId, first, { note: 'Image from drag' });
- }
- return putImageOnBoard(first, { note: 'Image from drag' });
- }
- }
- const html = dt.getData('text/html') || '';
- const m = html.match(/src=["']([^"']+)["']/i);
- if (m && m[1]) {
- if (slotId) {
- return setSlotSrc(slotId, m[1], { note: 'Image from drag' });
- }
- return putImageOnBoard(m[1], { note: 'Image from drag' });
- }
- return false;
- }
-
- async function imageToBase64ForOllama(src, maxEdge = 1024) {
- if (!src) {
- return null;
- }
- const dataUrl = await srcToDataUrl(src);
- if (!dataUrl) {
- return null;
- }
- try {
- const img = await new Promise((resolve, reject) => {
- const el = new Image();
- el.onload = () => resolve(el);
- el.onerror = reject;
- el.src = dataUrl;
- });
- const w = img.naturalWidth || img.width || 0;
- const h = img.naturalHeight || img.height || 0;
- const edge = Math.max(w, h);
- const canvas = document.createElement('canvas');
- if (!edge || edge <= maxEdge) {
- canvas.width = Math.max(w, 1);
- canvas.height = Math.max(h, 1);
- canvas.getContext('2d').drawImage(img, 0, 0);
- } else {
- const scale = maxEdge / edge;
- canvas.width = Math.max(1, Math.round(w * scale));
- canvas.height = Math.max(1, Math.round(h * scale));
- canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
- }
- const jpeg = canvas.toDataURL('image/jpeg', 0.85);
- const i = jpeg.indexOf(',');
- return i >= 0 ? jpeg.slice(i + 1) : null;
- } catch (e) {
- console.warn('Assistent: vision resize failed', e);
- const i = dataUrl.indexOf(',');
- return i >= 0 ? dataUrl.slice(i + 1) : null;
- }
- }
-
- async function srcToDataUrl(src) {
- if (src.startsWith('data:')) {
- return src;
- }
- try {
- const resp = await fetch(src);
- const blob = await resp.blob();
- return await new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(String(reader.result || ''));
- reader.onerror = reject;
- reader.readAsDataURL(blob);
- });
- } catch (e) {
- console.warn('Assistent: vision fetch failed', e);
- return null;
- }
- }
-
- function loadSettings() {
- // One-shot: old default was write_prompt → migrate to ordinary комбайн.
- if (!localStorage.getItem(LS_PACK_ORDINARY_MIG)) {
- if (localStorage.getItem(LS_PACK) === 'write_prompt') {
- localStorage.setItem(LS_PACK, 'ordinary');
- }
- localStorage.setItem(LS_PACK_ORDINARY_MIG, '1');
- }
- const base = localStorage.getItem(LS_BASE);
- const model = localStorage.getItem(LS_MODEL);
- const pack = localStorage.getItem(LS_PACK);
- const persona = localStorage.getItem(LS_PERSONA);
- const view = localStorage.getItem(LS_VIEW);
- const auto = localStorage.getItem(LS_AUTO_VISION);
- const autoApply = localStorage.getItem(LS_AUTO_APPLY);
- const autoGen = localStorage.getItem(LS_AUTO_GENERATE);
- const autoCrit = localStorage.getItem(LS_AUTO_CRITIQUE);
- const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD);
- const parkLlm = localStorage.getItem(LS_PARK_LLM);
- const paneW = localStorage.getItem(LS_PANE_WIDTH);
- if (base && $('sa_base_url')) {
- $('sa_base_url').value = base;
- }
- if (pack && $('sa_pack')) {
- $('sa_pack').value = pack;
- }
- if (persona && $('sa_persona')) {
- $('sa_persona').value = persona;
- }
- if (auto != null && $('sa_auto_vision')) {
- $('sa_auto_vision').checked = auto === '1';
- }
- if ($('sa_auto_apply')) {
- $('sa_auto_apply').checked = autoApply == null ? true : autoApply === '1';
- }
- if ($('sa_auto_generate')) {
- $('sa_auto_generate').checked = autoGen == null ? true : autoGen === '1';
- }
- if ($('sa_auto_critique') && autoCrit != null) {
- $('sa_auto_critique').checked = autoCrit === '1';
- }
- if ($('sa_auto_download') && autoDl != null) {
- $('sa_auto_download').checked = autoDl === '1';
- }
- // Default OFF — parking a VL 7B before every Generate caused 1–2 min reloads.
- if ($('sa_park_llm')) {
- $('sa_park_llm').checked = parkLlm === '1';
- }
- if (model) {
- state.preferredModel = model;
- }
- const embed = localStorage.getItem(LS_EMBED);
- if (embed) {
- state.preferredEmbed = embed;
- }
- if (paneW) {
- document.documentElement.style.setProperty('--sa-image-width', paneW);
- }
- if (view === 'cards' || view === 'chat' || view === 'settings') {
- state.view = view;
- }
- const boardTab = localStorage.getItem(LS_BOARD_TAB);
- if (boardTab === 'refs' || boardTab === 'generate') {
- state.boardTab = boardTab;
- }
- }
-
- function collectUiState() {
- return {
- pack: $('sa_pack')?.value || defaultPackId(),
- persona: $('sa_persona')?.value || 'neutral',
- auto_vision: !!$('sa_auto_vision')?.checked,
- auto_apply: !!$('sa_auto_apply')?.checked,
- auto_generate: !!$('sa_auto_generate')?.checked,
- auto_critique: !!$('sa_auto_critique')?.checked,
- auto_download: !!$('sa_auto_download')?.checked,
- park_llm: !!$('sa_park_llm')?.checked,
- pane_width: localStorage.getItem(LS_PANE_WIDTH) || '',
- embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
- base_url: $('sa_base_url')?.value || '',
- model: $('sa_model')?.value || '',
- view: state.view || 'chat',
- board_tab: state.boardTab || 'generate',
- };
- }
-
- /**
- * Fills fields the browser has never seen from sqlite ui_state, so a fresh
- * browser on the same volume inherits the previous session. Existing localStorage wins.
- * auto_download is only ever restored when it is off — the danger flag stays opt-in.
- */
- async function applyDiskUiState() {
- const persist = diskPersist();
- if (!persist) {
- return;
- }
- let ui = null;
- try {
- ui = await persist.loadUiState();
- } catch (e) {
- return;
- }
- if (!ui || typeof ui !== 'object') {
- return;
- }
- const fill = (lsKey, value, apply) => {
- if (value == null || value === '' || localStorage.getItem(lsKey) != null) {
- return;
- }
- localStorage.setItem(lsKey, String(value));
- apply?.(String(value));
- };
- fill(LS_BASE, ui.base_url, (v) => { if ($('sa_base_url')) { $('sa_base_url').value = v; } });
- fill(LS_MODEL, ui.model, (v) => { state.preferredModel = v; });
- fill(LS_EMBED, ui.embed_model, (v) => { state.preferredEmbed = v; });
- fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
- fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
- fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
- if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') {
- fill(LS_VIEW, ui.view, (v) => { state.view = v; });
- }
- if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
- fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
- }
- for (const [key, lsKey, id] of [
- ['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
- ['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
- ['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'],
- ['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'],
- ['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'],
- ['park_llm', LS_PARK_LLM, 'sa_park_llm'],
- ]) {
- if (ui[key] == null || localStorage.getItem(lsKey) != null) {
- continue;
- }
- const on = ui[key] === true || ui[key] === '1' || ui[key] === 1;
- if (on && key === 'auto_download') {
- continue;
- }
- localStorage.setItem(lsKey, on ? '1' : '0');
- const el = $(id);
- if (el) {
- el.checked = on;
- }
- }
- }
-
- function saveUiStateToDisk() {
- diskPersist()?.saveUiState(collectUiState());
- }
-
- function saveSettings() {
- localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
- localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
- localStorage.setItem(LS_EMBED, $('sa_embed_model')?.value || state.preferredEmbed || '');
- localStorage.setItem(LS_PACK, $('sa_pack')?.value || defaultPackId());
- localStorage.setItem(LS_PERSONA, $('sa_persona')?.value || 'neutral');
- localStorage.setItem(LS_VIEW, state.view || 'chat');
- localStorage.setItem(LS_AUTO_VISION, $('sa_auto_vision')?.checked ? '1' : '0');
- localStorage.setItem(LS_AUTO_APPLY, $('sa_auto_apply')?.checked ? '1' : '0');
- localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0');
- localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
- localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
- localStorage.setItem(LS_PARK_LLM, $('sa_park_llm')?.checked ? '1' : '0');
- persistServerSettings();
- saveUiStateToDisk();
- }
-
- function persistServerSettings() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- const skills = {};
- document.querySelectorAll('#sa_skills_box input[data-skill]')?.forEach((el) => {
- skills[el.getAttribute('data-skill')] = !!el.checked;
- });
- const persona = $('sa_persona')?.value || 'neutral';
- const settings = {
- embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
- base_url: $('sa_base_url')?.value || '',
- [persona]: { skills },
- };
- genericRequest('AssistentSaveSettings', { settings }, () => {}, 0, () => {});
- }
-
- function applyConfigPayload(data, { applyDefaults = false } = {}) {
- if (!data || data.error) {
- return;
- }
- const prevPersona = state.config?.persona || $('sa_persona')?.value || '';
- const prevControls = state.config?.control_values && typeof state.config.control_values === 'object'
- ? { ...state.config.control_values }
- : null;
- state.config = data;
- if (data.exact && typeof data.exact === 'object') {
- state.exact = data.exact;
- }
- const aspectSource = data.exact?.aspect_table || data.model?.aspect_table;
- if (aspectSource && typeof aspectSource === 'object') {
- applyAspectTableFrom(aspectSource);
- }
- const profileSource = data.exact?.profiles || data.model?.profiles;
- if (profileSource && typeof profileSource === 'object') {
- state.kreaProfiles = profileSource;
- }
- if (data.ui?.pack_aliases) {
- PACK_ALIASES = { ...PACK_ALIASES, ...data.ui.pack_aliases };
- }
- if (data.ui?.welcome_html) {
- WELCOME_HTML = data.ui.welcome_html;
- }
- if (data.ui?.help_text) {
- HELP_TEXT = data.ui.help_text;
- }
- if (Array.isArray(data.ui?.slash) && data.ui.slash.length) {
- SLASH_COMMANDS = data.ui.slash.map((s) => ({
- cmd: s.cmd || '',
- hint: s.hint || '',
- action: s.action || '',
- }));
- }
- if (Array.isArray(data.ui?.slash_extra) && data.ui.slash_extra.length) {
- for (const s of data.ui.slash_extra) {
- const cmd = s.cmd || '';
- if (!cmd || SLASH_COMMANDS.some((c) => c.cmd === cmd)) {
- continue;
- }
- SLASH_COMMANDS.push({
- cmd,
- hint: s.hint || '',
- action: s.action || '',
- });
- }
- }
- if (data.ui?.help_extra) {
- HELP_TEXT = `${HELP_TEXT || ''}\n\n${data.ui.help_extra}`.trim();
- }
- state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : [];
- if (Array.isArray(data.personas)) {
- state.personas = data.personas;
- }
- renderPersonaOptions(data.personas || [], data.persona || data.default_persona);
- renderPackOptions(data.packs || [], applyDefaults ? data.assistant?.default_pack : null);
- renderChips(data.ui?.chips || []);
- renderSkillChecks(data.skills || [], state.enabledSkills);
- if (applyDefaults && data.assistant?.default_pack && $('sa_pack') && !localStorage.getItem(LS_PACK)) {
- $('sa_pack').value = data.assistant.default_pack;
- }
- if (data.assistant?.embed_model && !state.preferredEmbed) {
- state.preferredEmbed = data.assistant.embed_model;
- }
- const asst = data.assistant || {};
- if (asst.history_keep_turns != null) {
- HISTORY_KEEP_TURNS = Math.max(1, Number(asst.history_keep_turns) || 4);
- }
- if (asst.max_ref_slots != null) {
- MAX_REF_SLOTS = Math.max(1, Number(asst.max_ref_slots) || 4);
- }
- if (asst.context_prompt_max != null) {
- CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
- }
- if (asst.inventory_prompt_rich != null) {
- INVENTORY_PROMPT_RICH = Math.max(4, Number(asst.inventory_prompt_rich) || 12);
- }
- if (asst.inventory_prompt_names != null) {
- INVENTORY_PROMPT_NAMES = Math.max(INVENTORY_PROMPT_RICH, Number(asst.inventory_prompt_names) || 24);
- }
- fillKnobsFromConfig(data);
- if (applyDefaults || data.exact) {
- fillEmptyParamsFromExact();
- }
- const nextPersona = data.persona || $('sa_persona')?.value || '';
- let controlValues = data.control_values || data.exact?.controls || {};
- // Same persona refresh must not wipe in-flight slider drags / optimistic saves with disk defaults.
- if (!applyDefaults && prevControls && nextPersona === prevPersona) {
- controlValues = { ...controlValues, ...prevControls };
- state.config.control_values = controlValues;
- if (state.exact) {
- state.exact.controls = { ...(state.exact.controls || {}), ...prevControls };
- }
- }
- renderPersonaControls(data.controls || {}, controlValues);
- syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $('sa_persona')?.value))?.source);
- }
-
- function syncPersonaDeleteButton(source) {
- const btn = $('sa_persona_delete');
- if (!btn) {
- return;
- }
- const src = String(source || '');
- const canDelete = src === 'overlay' || src === 'overlay+bundled';
- btn.hidden = !canDelete;
- btn.disabled = !canDelete;
- }
-
- let controlSaveTimer = null;
- let controlsPointerDown = false;
- let pendingControlsRender = null;
-
- function renderPersonaControls(schema, values) {
- const box = $('sa_persona_controls');
- if (!box) {
- return;
- }
- // Don't rebuild DOM while the user is dragging — that snaps the thumb back.
- if (controlsPointerDown) {
- pendingControlsRender = { schema, values };
- return;
- }
- pendingControlsRender = null;
- box.innerHTML = '';
- const keys = schema && typeof schema === 'object' ? Object.keys(schema) : [];
- if (!keys.length) {
- box.hidden = true;
- return;
- }
- box.hidden = false;
- const ordered = keys.slice().sort((a, b) => {
- const oa = Number(schema[a]?.order ?? 100);
- const ob = Number(schema[b]?.order ?? 100);
- if (oa !== ob) {
- return oa - ob;
- }
- return String(a).localeCompare(String(b));
- });
- for (const id of ordered) {
- const def = schema[id];
- if (!def || typeof def !== 'object') {
- continue;
- }
- if (String(def.type || 'slider').toLowerCase() !== 'slider') {
- continue;
- }
- const min = Number(def.min ?? -1);
- const max = Number(def.max ?? 1);
- const step = Number(def.step ?? 0.05);
- const defVal = Number(def.default ?? 0);
- let cur = values && values[id] != null ? Number(values[id]) : defVal;
- if (Number.isNaN(cur)) {
- cur = defVal;
- }
- const asPercent = String(def.display || '').toLowerCase() === 'percent';
- const fmt = (v) => (asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2));
- const row = document.createElement('div');
- row.className = 'sa-control-row';
- row.title = def.hint || id;
- const lab = document.createElement('label');
- lab.textContent = def.label || id;
- const input = document.createElement('input');
- input.type = 'range';
- input.min = String(min);
- input.max = String(max);
- input.step = String(step);
- input.value = String(cur);
- input.dataset.controlId = id;
- const valEl = document.createElement('span');
- valEl.className = 'sa-control-val';
- valEl.textContent = fmt(cur);
- const applyLocal = (v) => {
- valEl.textContent = fmt(v);
- if (state.config) {
- state.config.control_values = { ...(state.config.control_values || {}), [id]: v };
- }
- if (state.exact) {
- state.exact.controls = { ...(state.exact.controls || {}), [id]: v };
- }
- };
- input.addEventListener('pointerdown', () => {
- controlsPointerDown = true;
- });
- const endPointer = () => {
- const v = Number(input.value);
- applyLocal(v);
- controlsPointerDown = false;
- // Discard mid-drag rebuilds that carried stale server defaults — keep local values.
- if (pendingControlsRender) {
- const schema = pendingControlsRender.schema;
- pendingControlsRender = null;
- renderPersonaControls(
- schema,
- state.config?.control_values || state.exact?.controls || {},
- );
- }
- if (controlSaveTimer) {
- clearTimeout(controlSaveTimer);
- }
- controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
- };
- input.addEventListener('pointerup', endPointer);
- input.addEventListener('pointercancel', endPointer);
- // Live label while dragging; also persist on change for keyboard tweaks.
- input.addEventListener('input', () => {
- applyLocal(Number(input.value));
- });
- input.addEventListener('change', () => {
- const v = Number(input.value);
- applyLocal(v);
- if (controlSaveTimer) {
- clearTimeout(controlSaveTimer);
- }
- controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
- });
- row.appendChild(lab);
- row.appendChild(input);
- row.appendChild(valEl);
- box.appendChild(row);
- }
- }
-
- function getControlValue(id, fallback) {
- const v = state.config?.control_values?.[id] ?? state.exact?.controls?.[id];
- const n = Number(v);
- return Number.isFinite(n) ? n : fallback;
- }
-
- function coolDownHorny() {
- const persona = $('sa_persona')?.value || '';
- if (persona !== 'leonid') {
- setStatus('/остынь только для Leonid');
- return;
- }
- state.lastUserControlIntent = true;
- const schema = state.config?.controls || {};
- if (!schema.horny) {
- setStatus('У этой личности нет слайдера Хорни');
- return;
- }
- const min = Number(schema.horny.min ?? 0);
- const max = Number(schema.horny.max ?? 100);
- const cur = getControlValue('horny', Number(schema.horny.default ?? 35));
- const next = Math.max(min, Math.min(max, cur - 30));
- const values = {
- ...(state.config?.control_values || state.exact?.controls || {}),
- horny: next,
- };
- if (state.config) {
- state.config.control_values = values;
- }
- if (state.exact) {
- state.exact.controls = values;
- }
- renderPersonaControls(schema, values);
- savePersonaControls({ horny: next });
- appendSystemNote(`Хорни: ${Math.round(cur)}% → ${Math.round(next)}% (−30)`);
- setStatus(`/остынь → ${Math.round(next)}%`);
- }
-
- async function startHornyGame() {
- const persona = $('sa_persona')?.value || '';
- if (persona !== 'leonid') {
- setStatus('/horny-game только для Leonid');
- return;
- }
- const cur = getControlValue('horny', 35);
- state.lastUserControlIntent = true;
- await sendChat({
- skipSlash: true,
- skipAutoPack: true,
- forcedUserText:
- `Команда /horny-game. Текущий controls.horny = ${Math.round(cur)} (0–100).\n` +
- `Оцени, насколько вкусы пользователя в этом чате / последнем сообщении совпадают с твоими (roleplay, outfits, realism, fetishes).\n` +
- `Поставь новый controls.horny: умножь/сдвинь текущее значение пропорционально «насколько тебе это зашло» ` +
- `(слабое совпадение → чуть вниз или почти без изменений; сильное → заметный рост, clamp 0–100).\n` +
- `В прозе скажи кратко: совпало ли, какой множитель/сдвиг и новый %. ` +
- `Обязателен JSON patch с "controls": { "horny": }. Без generate, если не просили картинку.`,
- });
- setStatus('/horny-game…');
- }
-
- function savePersonaControls(partial) {
- const persona = $('sa_persona')?.value || 'neutral';
- if (typeof genericRequest !== 'function') {
- return;
- }
- // Optimistic local merge so UI / next chat see the new values immediately.
- if (partial && typeof partial === 'object') {
- if (state.config) {
- state.config.control_values = { ...(state.config.control_values || {}), ...partial };
- }
- if (state.exact) {
- state.exact.controls = { ...(state.exact.controls || {}), ...partial };
- }
- }
- genericRequest(
- 'AssistentSaveControls',
- { persona, controls: partial || {} },
- (data) => {
- if (data?.error) {
- setStatus(data.error);
- return;
- }
- if (data?.control_values && state.config) {
- state.config.control_values = data.control_values;
- if (state.exact) {
- state.exact.controls = data.control_values;
- }
- }
- // Do not rebuild slider DOM here — that interrupts an in-progress drag and snaps values back.
- // Update live inputs in place if present and not being dragged.
- if (!controlsPointerDown && data?.control_values) {
- syncPersonaControlInputs(data.control_values);
- }
- },
- 0,
- () => setStatus('controls save failed'),
- );
- }
-
- function syncPersonaControlInputs(values) {
- const box = $('sa_persona_controls');
- if (!box || !values || typeof values !== 'object') {
- return;
- }
- box.querySelectorAll('input[data-control-id]').forEach((input) => {
- const id = input.dataset.controlId;
- if (values[id] == null) {
- return;
- }
- const v = Number(values[id]);
- if (!Number.isFinite(v) || input.value === String(v)) {
- return;
- }
- input.value = String(v);
- const valEl = input.parentElement?.querySelector('.sa-control-val');
- if (valEl) {
- const schema = state.config?.controls?.[id];
- const asPercent = String(schema?.display || '').toLowerCase() === 'percent';
- valEl.textContent = asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2);
- }
- });
- }
-
- async function deleteCurrentOverlayPersona() {
- const id = $('sa_persona')?.value;
- if (!id) {
- return;
- }
- const meta = (state.personas || []).find((p) => p.id === id);
- const title = meta?.title || id;
- const src = meta?.source || state.config?.persona_source || '';
- if (src !== 'overlay' && src !== 'overlay+bundled') {
- setStatus('Bundled personas cannot be deleted');
- return;
- }
- if (!window.confirm(`Удалить «${title}»?\nПоставка (bundled) не трогается.`)) {
- return;
- }
- await new Promise((resolve) => {
- genericRequest(
- 'AssistentDeletePersona',
- { persona: id },
- async (data) => {
- if (data?.error) {
- setStatus(data.error);
- resolve();
- return;
- }
- const next = data?.default_persona || 'neutral';
- if (Array.isArray(data?.personas)) {
- state.personas = data.personas;
- }
- renderPersonaOptions(state.personas || [], next);
- if ($('sa_persona')) {
- $('sa_persona').value = next;
- }
- await applyPersonaForChat(next, { quiet: false });
- setStatus(`Удалено: ${id}`);
- resolve();
- },
- 0,
- () => { setStatus('delete failed'); resolve(); },
- );
- });
- }
-
- function renderPersonaOptions(personas, selected) {
- const sel = $('sa_persona');
- if (!sel) {
- return;
- }
- const cur = selected || sel.value || localStorage.getItem(LS_PERSONA) || 'neutral';
- sel.innerHTML = '';
- for (const p of personas) {
- const opt = document.createElement('option');
- opt.value = p.id;
- opt.textContent = p.title || p.id;
- if (p.accent) {
- opt.dataset.accent = p.accent;
- }
- sel.appendChild(opt);
- }
- if ([...sel.options].some((o) => o.value === cur)) {
- sel.value = cur;
- }
- const meta = (personas || []).find((p) => p.id === sel.value);
- syncPersonaDeleteButton(meta?.source || state.config?.persona_source);
- }
-
- function renderPackOptions(packs, preferred) {
- const sel = $('sa_pack');
- if (!sel) {
- return;
- }
- const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || defaultPackId();
- sel.innerHTML = '';
- const list = (packs || []).slice().sort((a, b) => (a.order || 100) - (b.order || 100));
- for (const p of list) {
- const opt = document.createElement('option');
- opt.value = p.id;
- opt.textContent = p.title || p.id;
- sel.appendChild(opt);
- }
- if ([...sel.options].some((o) => o.value === cur)) {
- sel.value = cur;
- }
- }
-
- function renderChips(chips) {
- const box = $('sa_chips');
- if (!box || !Array.isArray(chips) || !chips.length) {
- return;
- }
- box.innerHTML = '';
- for (const c of chips) {
- if (c.sep) {
- const sep = document.createElement('span');
- sep.className = 'sa-chip-sep';
- sep.setAttribute('aria-hidden', 'true');
- box.appendChild(sep);
- continue;
- }
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'sa-chip';
- btn.textContent = c.label || c.value || '';
- if (c.title) {
- btn.title = c.title;
- }
- const action = c.action || '';
- const value = c.value ?? '';
- if (action === 'aspect') {
- btn.setAttribute('data-aspect', value);
- } else if (action === 'seed') {
- btn.setAttribute('data-seed', value);
- } else if (action === 'vary') {
- btn.setAttribute('data-vary', value || '1');
- } else if (action === 'krea_profile') {
- btn.setAttribute('data-krea-profile', value);
- }
- box.appendChild(btn);
- }
- }
-
- function renderSkillChecks(skills, enabled) {
- const box = $('sa_skills_box');
- if (!box) {
- return;
- }
- const on = new Set(enabled || []);
- box.innerHTML = '';
- for (const s of skills || []) {
- const label = document.createElement('label');
- label.className = 'sa-check';
- const input = document.createElement('input');
- input.type = 'checkbox';
- input.setAttribute('data-skill', s.id);
- input.checked = on.has(s.id) || (!enabled?.length && !!s.default);
- input.addEventListener('change', () => {
- state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
- saveSettings();
- });
- label.appendChild(input);
- label.appendChild(document.createTextNode(` ${s.title || s.id}`));
- box.appendChild(label);
- }
- state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
- }
-
- function loadConfig(persona, done) {
- if (typeof genericRequest !== 'function') {
- done?.(null);
- return;
- }
- genericRequest(
- 'AssistentGetConfig',
- { persona: persona || $('sa_persona')?.value || 'neutral' },
- (data) => {
- applyConfigPayload(data, { applyDefaults: true });
- done?.(data);
- },
- 0,
- () => done?.(null),
- );
- }
-
- /** Larger param tags win (32b > 8b > 7b); instruct / qwen3 preferred over thinking/:latest. */
- function chatModelSeniority(name) {
- const n = String(name || '').toLowerCase();
- let score = 0;
- const m = n.match(/(?:^|[:\-/])(\d+)\s*b\b/);
- if (m) {
- score += Number(m[1]) * 1e6;
- }
- if (n.includes('instruct')) {
- score += 5e4;
- }
- if (n.includes('qwen3')) {
- score += 2e4;
- }
- if (n.includes('thinking') || n.endsWith(':latest')) {
- score -= 1e4;
- }
- return score;
- }
-
- function pickSeniorChatModel(names) {
- const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
- if (!list.length) {
- return '';
- }
- return [...list].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b))[0];
- }
-
- /**
- * Resolve which chat tag to select / warm.
- * Priority: api preferred (ollama-roles default_chat) → senior heuristic → LS (after one-shot junior upgrade).
- */
- function resolveChatModel(names, apiPreferred) {
- const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
- if (!list.length) {
- return '';
- }
- const preferred = apiPreferred && list.includes(apiPreferred)
- ? apiPreferred
- : pickSeniorChatModel(list);
- const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
- if (!localStorage.getItem(LS_MODEL_SENIOR_MIG)) {
- localStorage.setItem(LS_MODEL_SENIOR_MIG, '1');
- if (preferred && (!ls || !list.includes(ls) || chatModelSeniority(ls) < chatModelSeniority(preferred))) {
- state.preferredModel = preferred;
- return preferred;
- }
- }
- if (ls && list.includes(ls)) {
- return ls;
- }
- return preferred || list[0];
- }
-
- function setModelOptions(models, { error, preferred } = {}) {
- const sel = $('sa_model');
- const sel2 = $('sa_settings_chat_model');
- const apply = (target) => {
- if (!target) {
- return;
- }
- let names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
- names = [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b));
- target.innerHTML = '';
- if (error) {
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
- target.appendChild(opt);
- target.disabled = true;
- return;
- }
- target.disabled = false;
- if (!names.length) {
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = 'No Ollama models — pull / Refresh';
- target.appendChild(opt);
- return;
- }
- for (const name of names) {
- const opt = document.createElement('option');
- opt.value = name;
- opt.textContent = name;
- target.appendChild(opt);
- }
- const pick = resolveChatModel(names, preferred);
- if (pick) {
- target.value = pick;
- }
- };
- apply(sel);
- apply(sel2);
- }
-
- function setEmbedModelOptions(models) {
- const sel = $('sa_embed_model');
- if (!sel) {
- return;
- }
- const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
- sel.innerHTML = '';
- if (!names.length) {
- const opt = document.createElement('option');
- opt.value = state.preferredEmbed || 'nomic-embed-text';
- opt.textContent = opt.value + ' (ожидается pull)';
- sel.appendChild(opt);
- return;
- }
- for (const name of names) {
- const opt = document.createElement('option');
- opt.value = name;
- opt.textContent = name;
- sel.appendChild(opt);
- }
- const prefer = state.preferredEmbed || localStorage.getItem(LS_EMBED) || state.config?.assistant?.embed_model;
- if (prefer && names.includes(prefer)) {
- sel.value = prefer;
- } else if (prefer && !names.includes(prefer)) {
- const opt = document.createElement('option');
- opt.value = prefer;
- opt.textContent = prefer;
- sel.appendChild(opt);
- sel.value = prefer;
- }
- }
-
- function refreshModels() {
- const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
- setStatus('Loading models…');
- if (typeof genericRequest !== 'function') {
- setStatus('SwarmUI API not ready');
- setModelOptions([], { error: 'SwarmUI API not ready' });
- return;
- }
- genericRequest(
- 'AssistentListModels',
- { baseUrl },
- (data) => {
- const models = data.models || [];
- const memoryModels = data.memory_models || [];
- const preferred = (data.preferred || '').trim();
- setModelOptions(models, { preferred });
- setEmbedModelOptions(memoryModels);
- const pick = resolveChatModel(models, preferred);
- if (pick && $('sa_model')) {
- $('sa_model').value = pick;
- if ($('sa_settings_chat_model')) {
- $('sa_settings_chat_model').value = pick;
- }
- state.preferredModel = pick;
- localStorage.setItem(LS_MODEL, pick);
- }
- setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
- if (models.length) {
- setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`);
- } else {
- setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
- }
- saveSettings();
- },
- 0,
- (err) => {
- const msg = String(err || 'Ollama unreachable');
- setStatus(msg);
- setModelOptions([], { error: msg });
- setOllamaHealth('down', 'Ollama ✕', msg);
- appendMessage('error', msg);
- },
- );
- }
-
- function refreshInventory(done, opts = {}) {
- if (typeof genericRequest !== 'function') {
- if (done) {
- done();
- }
- return;
- }
- const rescan = !!opts.rescan;
- genericRequest(
- 'AssistentListInventory',
- { rescan },
- (data) => {
- state.inventory = {
- loras: data.loras || [],
- checkpoints: data.checkpoints || [],
- wildcards: data.wildcards || [],
- has_civitai_key: !!data.has_civitai_key,
- inventory_at: data.inventory_at || Math.floor(Date.now() / 1000),
- rescanned: !!data.rescanned,
- };
- state.inventoryFetchedAt = Date.now();
- const n = state.inventory.loras.length;
- const ck = state.inventory.checkpoints.length;
- setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? ' (rescanned)' : ''}`);
- prefetchActiveModelCards();
- if (state.view === 'cards') {
- renderCardsList();
- }
- if (done) {
- done(state.inventory);
- }
- },
- 0,
- (err) => {
- console.warn('Assistent inventory', err);
- if (done) {
- done(null);
- }
- },
- );
- }
-
- function refreshInventoryAsync(opts = {}) {
- return new Promise((resolve) => refreshInventory(resolve, opts));
- }
-
- function memoryKindFilter() {
- return $('sa_mem_kind')?.value || 'all';
- }
-
- function memoryScopeFilter() {
- return $('sa_mem_scope')?.value || 'all';
- }
-
- function memorySearchFilter() {
- return ($('sa_mem_search')?.value || '').trim().toLowerCase();
- }
-
- function renderMemoryKinds(kinds) {
- const sel = $('sa_mem_kind');
- if (!sel) {
- return;
- }
- const cur = sel.value || 'all';
- sel.innerHTML = '';
- const all = document.createElement('option');
- all.value = 'all';
- all.textContent = 'Все типы';
- sel.appendChild(all);
- for (const kind of kinds || []) {
- const opt = document.createElement('option');
- opt.value = kind;
- opt.textContent = kind;
- sel.appendChild(opt);
- }
- if ([...sel.options].some((o) => o.value === cur)) {
- sel.value = cur;
- }
- }
-
- function filteredMemoryRows() {
- const filter = memoryKindFilter();
- const scope = memoryScopeFilter();
- const q = memorySearchFilter();
- const persona = $('sa_persona')?.value || 'neutral';
- return (state.memoryRows || []).filter((m) => {
- if (filter !== 'all' && m.kind !== filter) {
- return false;
- }
- if (scope === 'shared' && m.scope !== 'shared') {
- return false;
- }
- if (scope === 'personal' && !(m.scope === 'personal' && (m.persona === persona || !m.persona))) {
- return false;
- }
- if (q) {
- const hay = `${m.kind || ''} ${m.key || ''} ${m.text || ''}`.toLowerCase();
- if (!hay.includes(q)) {
- return false;
- }
- }
- return true;
- });
- }
-
- function renderMemoryList() {
- const root = $('sa_mem_list');
- if (!root) {
- return;
- }
- const rows = filteredMemoryRows();
- root.innerHTML = '';
- if (!rows.length) {
- root.innerHTML = 'Крафт-память пуста — карточки, seed и патчи memory_upsert.
';
- return;
- }
- for (const row of rows) {
- const el = document.createElement('div');
- el.className = 'sa-mem-row';
- const bundled = row.source === 'bundled';
- const when = row.updated ? formatChatWhen(row.updated * 1000) : '';
- const scope = row.scope === 'personal' ? `персона ${row.persona || '—'}` : 'общая';
- el.innerHTML = `${escapeHtml(row.kind || 'note')} ${escapeHtml(row.key || '')}
${escapeHtml(clipDebug(row.text, 220))}
${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}
`;
- const forget = document.createElement('button');
- forget.type = 'button';
- forget.className = 'basic-button sa-mem-forget';
- forget.textContent = '×';
- if (bundled) {
- forget.disabled = true;
- forget.title = 'Bundled — вернётся при reseed, правь Config/_base/memory-seed/';
- } else {
- forget.title = 'Забыть';
- forget.addEventListener('click', () => forgetMemory(row));
- }
- el.appendChild(forget);
- root.appendChild(el);
- }
- }
-
- function refreshMemoryList() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- const list = $('sa_mem_list');
- if (list && !state.memoryRows.length) {
- list.innerHTML = 'Читаю память…
';
- }
- genericRequest(
- 'AssistentListMemory',
- { limit: 200 },
- (data) => {
- state.memoryRows = Array.isArray(data?.memories) ? data.memories : [];
- renderMemoryKinds(data?.kinds || []);
- renderMemoryList();
- const foot = $('sa_mem_total');
- if (foot) {
- foot.textContent = `Всего: ${data?.total ?? state.memoryRows.length} · ${data?.embed_model || '—'}`;
- }
- },
- 0,
- (err) => {
- if (list) {
- list.innerHTML = `Память недоступна: ${escapeHtml(String(err || 'ошибка'))}
`;
- }
- },
- );
- }
-
- function forgetMemory(row) {
- if (!row?.kind || !row?.key || typeof genericRequest !== 'function') {
- return;
- }
- genericRequest(
- 'AssistentForgetMemory',
- {
- kind: row.kind,
- key: row.key,
- source: row.source || '',
- scope: row.scope || '',
- persona: row.scope === 'personal' ? (row.persona || '') : '',
- },
- () => {
- state.memoryRows = (state.memoryRows || []).filter((m) => !(m.kind === row.kind && m.key === row.key && m.source === row.source && m.persona === row.persona));
- renderMemoryList();
- setStatus(`Забыто: ${row.kind}/${row.key}`);
- },
- 0,
- (err) => setStatus(String(err || 'Не удалось забыть')),
- );
- }
-
- function clearCraftMemory(opts = {}) {
- if (typeof genericRequest !== 'function') {
- return;
- }
- const label = opts.label || 'крафт-память';
- if (!window.confirm(`Очистить ${label}? Bundled seed останется.`)) {
- return;
- }
- const body = {
- scope: opts.scope || '',
- kind: opts.kind || '',
- persona: opts.persona || '',
- };
- genericRequest(
- 'AssistentClearMemory',
- body,
- (data) => {
- setStatus(`Удалено крафт-записей: ${data?.deleted ?? 0}`);
- refreshMemoryList();
- },
- 0,
- (err) => setStatus(String(err || 'Очистка не удалась')),
- );
- }
-
- function setSettingsTab(id) {
- state.settingsTab = id || 'behavior';
- document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
- const on = btn.getAttribute('data-stab') === state.settingsTab;
- btn.classList.toggle('sa-stab-active', on);
- btn.setAttribute('aria-selected', on ? 'true' : 'false');
- });
- document.querySelectorAll('#sa_settings .sa-spane').forEach((pane) => {
- pane.hidden = pane.getAttribute('data-spane') !== state.settingsTab;
- });
- if (state.settingsTab === 'craft') {
- refreshMemoryList();
- refreshWantedQueue();
- }
- if (state.settingsTab === 'user') {
- refreshUserPrefs();
- }
- if (state.settingsTab === 'personas') {
- renderPersonaSettingsList();
- }
- if (state.settingsTab === 'models') {
- syncSettingsHealthLine();
- const m = $('sa_model')?.value;
- if (m && $('sa_settings_chat_model')) {
- $('sa_settings_chat_model').value = m;
- }
- }
- if (state.settingsTab === 'more') {
- fillKnobsFromConfig(state.config);
- }
- }
-
- function fillKnobsFromConfig(data) {
- const asst = data?.assistant || state.config?.assistant || {};
- const exact = data?.exact || state.config?.exact || state.exact || {};
- const setNum = (id, v) => {
- const el = $(id);
- if (el && v != null && Number.isFinite(Number(v))) {
- el.value = String(v);
- }
- };
- setNum('sa_num_ctx', asst.num_ctx);
- setNum('sa_history_keep', asst.history_keep_turns);
- setNum('sa_memory_top_k', asst.memory_top_k);
- const w = asst.user_prefs_weight != null ? Number(asst.user_prefs_weight) : 1;
- const weightEl = $('sa_user_prefs_weight');
- if (weightEl) {
- weightEl.value = String(Math.max(0, Math.min(1.5, w)));
- const lab = $('sa_user_prefs_weight_val');
- if (lab) {
- lab.textContent = Number(weightEl.value).toFixed(1);
- }
- }
- const turbo = exact.profiles?.turbo || {};
- const raw = exact.profiles?.raw || {};
- setNum('sa_exact_turbo_steps', turbo.steps);
- setNum('sa_exact_turbo_cfg', turbo.cfg);
- setNum('sa_exact_turbo_sigma', turbo.sigma_shift);
- setNum('sa_exact_raw_steps', raw.steps);
- setNum('sa_exact_raw_cfg', raw.cfg);
- setNum('sa_exact_raw_sigma', raw.sigma_shift);
- }
-
- function saveKnobs() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- const num = (id) => {
- const v = parseFloat($(id)?.value);
- return Number.isFinite(v) ? v : null;
- };
- const assistant = {
- num_ctx: num('sa_num_ctx'),
- history_keep_turns: num('sa_history_keep'),
- memory_top_k: num('sa_memory_top_k'),
- user_prefs_weight: num('sa_user_prefs_weight'),
- };
- Object.keys(assistant).forEach((k) => {
- if (assistant[k] == null) {
- delete assistant[k];
- }
- });
- const exact = {
- profiles: {
- turbo: {
- steps: num('sa_exact_turbo_steps'),
- cfg: num('sa_exact_turbo_cfg'),
- sigma_shift: num('sa_exact_turbo_sigma'),
- },
- raw: {
- steps: num('sa_exact_raw_steps'),
- cfg: num('sa_exact_raw_cfg'),
- sigma_shift: num('sa_exact_raw_sigma'),
- },
- },
- };
- genericRequest(
- 'AssistentSaveKnobs',
- { assistant, exact },
- (data) => {
- if (data?.assistant || data?.exact) {
- applyConfigPayload({
- ...state.config,
- assistant: data.assistant || state.config?.assistant,
- exact: data.exact || state.config?.exact,
- });
- }
- setStatus('Knobs сохранены в overlay');
- },
- 0,
- (err) => setStatus(String(err || 'Не удалось сохранить knobs')),
- );
- }
-
- function syncSettingsHealthLine() {
- const line = $('sa_settings_health_line');
- const badge = $('sa_ollama_health');
- if (line && badge) {
- line.textContent = badge.textContent || 'Ollama · …';
- line.className = 'sa-settings-health ' + (badge.className || '').replace('sa-health', '').trim();
- }
- }
-
- function personaSourceLabel(source) {
- if (source === 'overlay') {
- return 'моя';
- }
- if (source === 'overlay+bundled') {
- return 'встроено+правка';
- }
- return 'встроено';
- }
-
- function renderPersonaSettingsList() {
- const root = $('sa_persona_list');
- if (!root) {
- return;
- }
- const list = state.personas || state.config?.personas || [];
- const cur = state.settingsPersonaId || $('sa_persona')?.value || list[0]?.id;
- state.settingsPersonaId = cur;
- root.innerHTML = '';
- for (const p of list) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'sa-persona-item' + (p.id === cur ? ' sa-persona-item-active' : '');
- const accent = p.accent || 'currentColor';
- btn.innerHTML = `${escapeHtml(p.title || p.id)}
${escapeHtml(personaSourceLabel(p.source))}
`;
- btn.addEventListener('click', () => {
- state.settingsPersonaId = p.id;
- renderPersonaSettingsList();
- loadPersonaPreview(p.id);
- });
- root.appendChild(btn);
- }
- syncPersonaPanelActions();
- if (cur) {
- loadPersonaPreview(cur);
- }
- }
-
- function syncPersonaPanelActions() {
- const id = state.settingsPersonaId;
- const p = (state.personas || []).find((x) => x.id === id);
- const canDelete = p && (p.source === 'overlay' || p.source === 'overlay+bundled');
- const del = $('sa_btn_persona_delete_panel');
- if (del) {
- del.disabled = !canDelete;
- }
- }
-
- function loadPersonaPreview(id) {
- const box = $('sa_persona_preview');
- if (!box || typeof genericRequest !== 'function') {
- return;
- }
- box.innerHTML = 'Загрузка…
';
- genericRequest(
- 'AssistentGetPersonaShelves',
- { persona: id },
- (data) => {
- const summary = data?.identity_summary || '';
- const src = data?.source || '';
- box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
- syncPersonaPanelActions();
- },
- 0,
- (err) => {
- box.innerHTML = `${escapeHtml(String(err || 'ошибка'))}
`;
- },
- );
- }
-
- function exportSelectedPersona() {
- const id = state.settingsPersonaId || $('sa_persona')?.value;
- if (!id || typeof genericRequest !== 'function') {
- return;
- }
- genericRequest(
- 'AssistentExportPersona',
- { persona: id },
- (data) => {
- const pack = data?.pack;
- if (!pack) {
- setStatus('Пустой экспорт');
- return;
- }
- const blob = new Blob([JSON.stringify(pack, null, 2)], { type: 'application/json' });
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = `${pack.id || id}.assistent-persona.json`;
- a.click();
- URL.revokeObjectURL(a.href);
- setStatus(`Экспорт: ${a.download}`);
- },
- 0,
- (err) => setStatus(String(err || 'Экспорт не удался')),
- );
- }
-
- function importPersonaFile(file) {
- if (!file || typeof genericRequest !== 'function') {
- return;
- }
- const reader = new FileReader();
- reader.onload = () => {
- let pack;
- try {
- pack = JSON.parse(String(reader.result || ''));
- } catch (e) {
- setStatus('Невалидный JSON');
- return;
- }
- let newId = pack?.id || '';
- if ((state.personas || []).some((p) => p.id === newId && (p.source === 'bundled' || p.source === 'overlay+bundled'))) {
- newId = window.prompt('Id занят bundled — новый id:', `${newId}_import`) || '';
- }
- genericRequest(
- 'AssistentImportPersona',
- { pack, new_id: newId || null, overwrite: false },
- (data) => {
- if (Array.isArray(data?.personas)) {
- state.personas = data.personas;
- renderPersonaOptions(data.personas, data.persona?.id);
- }
- state.settingsPersonaId = data?.persona?.id || newId;
- renderPersonaSettingsList();
- setStatus(`Импорт: ${data?.persona?.id || newId}`);
- },
- 0,
- (err) => setStatus(String(err || 'Импорт не удался')),
- );
- };
- reader.readAsText(file);
- }
-
- function cloneSelectedPersona() {
- const from = state.settingsPersonaId || $('sa_persona')?.value;
- if (!from) {
- return;
- }
- const to = window.prompt('Новый id личности:', `${from}_copy`);
- if (!to) {
- return;
- }
- genericRequest(
- 'AssistentClonePersona',
- { from, to, title: to },
- (data) => {
- if (Array.isArray(data?.personas)) {
- state.personas = data.personas;
- renderPersonaOptions(data.personas, to);
- }
- state.settingsPersonaId = to;
- renderPersonaSettingsList();
- setStatus(`Клон: ${to}`);
- },
- 0,
- (err) => setStatus(String(err || 'Клон не удался')),
- );
- }
-
- function deleteSelectedOverlayPersona() {
- const id = state.settingsPersonaId;
- const p = (state.personas || []).find((x) => x.id === id);
- if (!p || (p.source !== 'overlay' && p.source !== 'overlay+bundled')) {
- setStatus('Можно удалить только overlay');
- return;
- }
- if (!window.confirm(`Удалить overlay-личность «${id}»?`)) {
- return;
- }
- genericRequest(
- 'AssistentDeletePersona',
- { persona: id },
- (data) => {
- if (Array.isArray(data?.personas)) {
- state.personas = data.personas;
- renderPersonaOptions(data.personas, data.default_persona);
- }
- state.settingsPersonaId = data?.default_persona || null;
- renderPersonaSettingsList();
- setStatus(`Удалено: ${id}`);
- },
- 0,
- (err) => setStatus(String(err || 'Удаление не удалось')),
- );
- }
-
- function refreshUserPrefs() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- const persona = $('sa_persona')?.value || 'neutral';
- genericRequest(
- 'AssistentListUserPrefs',
- { persona, limit: 200 },
- (data) => {
- state.userPrefs = Array.isArray(data?.prefs) ? data.prefs : [];
- renderUserPrefsLists();
- },
- 0,
- (err) => setStatus(String(err || 'User prefs недоступны')),
- );
- }
-
- function renderUserPrefsLists() {
- const persona = $('sa_persona')?.value || 'neutral';
- const global = (state.userPrefs || []).filter((p) => p.scope === 'global');
- const personal = (state.userPrefs || []).filter((p) => p.scope === 'persona' && (p.persona_id === persona || p.persona === persona));
- const fill = (rootId, rows) => {
- const root = $(rootId);
- if (!root) {
- return;
- }
- root.innerHTML = '';
- if (!rows.length) {
- root.innerHTML = 'Пусто
';
- return;
- }
- for (const row of rows) {
- const el = document.createElement('div');
- el.className = 'sa-mem-row';
- const pin = row.pinned ? ' ★' : '';
- el.innerHTML = `${escapeHtml(row.key || '')}${pin}
${escapeHtml(clipDebug(row.text, 200))}
`;
- el.querySelector('.sa-mem-row-body')?.addEventListener('click', () => editUserPref(row));
- el.querySelector('.sa-mem-row-body')?.setAttribute('title', 'Клик — редактировать');
- const pinBtn = document.createElement('button');
- pinBtn.type = 'button';
- pinBtn.className = 'basic-button sa-mem-forget';
- pinBtn.textContent = row.pinned ? '★' : '☆';
- pinBtn.title = row.pinned ? 'Unpin' : 'Pin';
- pinBtn.addEventListener('click', (e) => {
- e.stopPropagation();
- toggleUserPrefPin(row);
- });
- el.appendChild(pinBtn);
- const forget = document.createElement('button');
- forget.type = 'button';
- forget.className = 'basic-button sa-mem-forget';
- forget.textContent = '×';
- forget.title = 'Забыть';
- forget.addEventListener('click', (e) => {
- e.stopPropagation();
- forgetUserPref(row);
- });
- el.appendChild(forget);
- root.appendChild(el);
- }
- };
- fill('sa_prefs_global', global);
- fill('sa_prefs_persona', personal);
- }
-
- function editUserPref(row) {
- const text = window.prompt('Текст факта:', row.text || '');
- if (text == null || !String(text).trim()) {
- return;
- }
- genericRequest(
- 'AssistentUpsertUserPref',
- {
- key: row.key,
- text: String(text).trim(),
- scope: row.scope || 'global',
- persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
- source: 'user',
- pinned: !!row.pinned,
- },
- () => refreshUserPrefs(),
- 0,
- (err) => setStatus(String(err || 'Не удалось сохранить')),
- );
- }
-
- function toggleUserPrefPin(row) {
- genericRequest(
- 'AssistentUpsertUserPref',
- {
- key: row.key,
- text: row.text,
- scope: row.scope || 'global',
- persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
- source: row.source || 'user',
- pinned: !row.pinned,
- },
- () => refreshUserPrefs(),
- 0,
- (err) => setStatus(String(err || 'Не удалось pin')),
- );
- }
-
- function addUserPref(scope) {
- const key = window.prompt('Ключ (stable-id):', scope === 'global' ? 'prefer' : 'tone');
- if (!key) {
- return;
- }
- const text = window.prompt('Текст факта:', '');
- if (!text) {
- return;
- }
- genericRequest(
- 'AssistentUpsertUserPref',
- {
- key: key.trim(),
- text: text.trim(),
- scope,
- persona: $('sa_persona')?.value || 'neutral',
- source: 'user',
- pinned: false,
- },
- () => {
- refreshUserPrefs();
- setStatus('Сохранено');
- },
- 0,
- (err) => setStatus(String(err || 'Не удалось сохранить')),
- );
- }
-
- function forgetUserPref(row) {
- genericRequest(
- 'AssistentForgetUserPref',
- {
- key: row.key,
- scope: row.scope || 'global',
- persona: row.persona_id || row.persona || $('sa_persona')?.value,
- },
- () => refreshUserPrefs(),
- 0,
- (err) => setStatus(String(err || 'Не удалось забыть')),
- );
- }
-
- function clearUserPrefs(scope) {
- const labels = { global: 'общие prefs', persona: 'prefs этой личности', all: 'все prefs о пользователе' };
- if (!window.confirm(`Очистить ${labels[scope] || scope}?`)) {
- return;
- }
- genericRequest(
- 'AssistentClearUserPrefs',
- { scope, persona: $('sa_persona')?.value || 'neutral' },
- (data) => {
- setStatus(`Удалено: ${data?.deleted ?? 0}`);
- refreshUserPrefs();
- },
- 0,
- (err) => setStatus(String(err || 'Очистка не удалась')),
- );
- }
-
- function resetUiState() {
- if (!window.confirm('Сбросить UI-state (local + disk)? Настройки Ollama и prefs останутся.')) {
- return;
- }
- const keys = Object.keys(localStorage).filter((k) => k.startsWith('swarm_assistent_'));
- for (const k of keys) {
- if (k === LS_TASTE) {
- continue;
- }
- localStorage.removeItem(k);
- }
- diskPersist()?.saveUiState?.({});
- setStatus('UI-state сброшен — обнови страницу');
- }
-
- function modelKeyLeaf(name) {
- return String(name || '')
- .replace(/\\/g, '/')
- .split('/')
- .pop()
- .replace(/\.(safetensors|ckpt|pt|pth|gguf|bin)$/i, '')
- .trim()
- .toLowerCase();
- }
-
- function refreshWantedQueue() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- genericRequest(
- 'AssistentListWanted',
- {},
- (data) => {
- const items = Array.isArray(data?.items) ? data.items : [];
- state.wanted = { count: data?.count ?? items.length, items };
- const keys = new Set();
- for (const item of items) {
- const leaf = modelKeyLeaf(item?.title);
- if (leaf) {
- keys.add(leaf);
- }
- if (item?.version_id) {
- keys.add(`v${item.version_id}`);
- }
- }
- state.wantedKeys = keys;
- const el = $('sa_mem_wanted');
- if (el) {
- el.textContent = state.wanted.count
- ? `Очередь wanted: ${state.wanted.count} (скачается на следующем up)`
- : 'Очередь wanted: пусто';
- el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join('\n');
- }
- if (state.view === 'cards') {
- renderCardsList();
- }
- },
- 0,
- () => {
- const el = $('sa_mem_wanted');
- if (el) {
- el.textContent = 'Очередь wanted: —';
- }
- },
- );
- }
-
- function isWantedModel(row) {
- const keys = state.wantedKeys;
- if (!keys || !keys.size) {
- return false;
- }
- for (const candidate of [row?.name, row?.title]) {
- const leaf = modelKeyLeaf(candidate);
- if (leaf && keys.has(leaf)) {
- return true;
- }
- }
- return false;
- }
-
- function setOllamaHealth(level, text, title) {
- state.ollamaHealth = level;
- const el = $('sa_ollama_health');
- if (!el) {
- return;
- }
- el.hidden = false;
- el.textContent = text;
- el.title = title || text;
- el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
- el.classList.add(`sa-health-${level}`);
- syncSettingsHealthLine();
- }
-
- function probeOllamaHealth() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
- genericRequest(
- 'AssistentListModels',
- { baseUrl },
- (data) => {
- if (data?.error) {
- setOllamaHealth('down', 'Ollama ✕', String(data.error));
- return;
- }
- const chat = (data.models || []).length;
- const mem = (data.memory_models || []).length;
- if (!chat) {
- setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
- return;
- }
- setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
- },
- 0,
- (err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`),
- );
- }
-
- function setCardStatus(msg) {
- const el = $('sa_card_status');
- if (el) {
- el.textContent = msg || '';
- }
- }
-
- function setView(view) {
- if (view === 'cards') {
- state.view = 'cards';
- } else if (view === 'settings') {
- state.view = 'settings';
- } else {
- state.view = 'chat';
- }
- const chat = $('sa_view_chat');
- const cards = $('sa_view_cards');
- const settings = $('sa_view_settings');
- if (chat) {
- chat.hidden = state.view !== 'chat';
- }
- if (cards) {
- cards.hidden = state.view !== 'cards';
- }
- if (settings) {
- settings.hidden = state.view !== 'settings';
- }
- $('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
- $('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
- $('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
- $('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
- saveSettings();
- if (state.view === 'cards') {
- renderCardsList();
- } else if (state.view === 'settings') {
- setSettingsTab(state.settingsTab || 'behavior');
- } else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
- // Back in the chat — bring the model home (Krea may have evicted it).
- warmLlm({ force: true });
- }
- }
-
- function openSettings(tab) {
- if (tab) {
- state.settingsTab = tab;
- }
- setView('settings');
- }
-
- function closeSettings() {
- setView('chat');
- }
-
- function refreshPersonas() {
- loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => {
- if (data?.personas) {
- state.personas = data.personas;
- }
- });
- }
-
- function prefetchCard(kind, name) {
- return new Promise((resolve) => {
- if (!kind || !name || typeof genericRequest !== 'function') {
- resolve(null);
- return;
- }
- const key = `${kind}:${name}`;
- genericRequest(
- 'AssistentGetCard',
- { kind, name },
- (data) => {
- if (data?.card) {
- state.modelCards[key] = data.card;
- }
- resolve(data?.card || null);
- },
- 0,
- () => resolve(null),
- );
- });
- }
-
- async function prefetchActiveModelCards() {
- const keys = [];
- const seen = new Set();
- const add = (kind, name) => {
- if (!kind || !name) {
- return;
- }
- const key = `${kind}:${name}`;
- if (seen.has(key)) {
- return;
- }
- seen.add(key);
- keys.push({ kind, name });
- };
- try {
- const ck = resolveCurrentCheckpoint();
- if (ck?.name) {
- add('checkpoint', ck.name);
- }
- } catch (e) { /* ignore */ }
- try {
- if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
- for (const l of loraHelper.selected) {
- add('lora', l?.name || l);
- }
- }
- } catch (e) { /* ignore */ }
- for (const l of state.inventory?.loras || []) {
- if (l?.has_card) {
- add('lora', l.name);
- }
- if (keys.length >= 14) {
- break;
- }
- }
- for (const c of state.inventory?.checkpoints || []) {
- if (c?.has_card) {
- add('checkpoint', c.name);
- }
- if (keys.length >= 16) {
- break;
- }
- }
- await Promise.all(keys.map((k) => prefetchCard(k.kind, k.name)));
- }
-
- function cardsCatalog() {
- const kind = $('sa_cards_kind')?.value || 'all';
- const inv = state.inventory || {};
- const rows = [];
- if (kind === 'all' || kind === 'checkpoint') {
- for (const c of inv.checkpoints || []) {
- rows.push({
- kind: 'checkpoint',
- name: c.name,
- title: c.title || c.name,
- has_card: !!c.has_card,
- hash: c.hash || '',
- preview_url: c.preview_url || null,
- has_sidecar: !!c.has_sidecar,
- });
- }
- }
- if (kind === 'all' || kind === 'lora') {
- for (const l of inv.loras || []) {
- rows.push({
- kind: 'lora',
- name: l.name,
- title: l.title || l.name,
- has_card: !!l.has_card,
- trigger: l.trigger_phrase,
- hash: l.hash || '',
- preview_url: l.preview_url || null,
- has_sidecar: !!l.has_sidecar,
- });
- }
- }
- return rows;
- }
-
- function renderCardsList() {
- const root = $('sa_cards_list');
- if (!root) {
- return;
- }
- root.innerHTML = '';
- const rows = cardsCatalog();
- if (!rows.length) {
- root.innerHTML = 'Inventory пуст — Обновить.
';
- 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
- ? ` `
- : '
';
- const metaBits = [];
- metaBits.push(row.has_card ? 'card ✓' : 'нет card');
- if (row.has_sidecar) {
- metaBits.push('sidecar');
- }
- if (isWantedModel(row)) {
- metaBits.push('⏳ wanted');
- btn.classList.add('sa-card-row-wanted');
- }
- if (row.trigger) {
- metaBits.push(String(row.trigger).slice(0, 40));
- }
- btn.innerHTML = `${thumb}${escapeHtml(row.kind)}
${escapeHtml(row.title || row.name)}
${escapeHtml(metaBits.join(' · '))}
↗ `;
- btn.addEventListener('click', (e) => {
- if (e.target?.closest?.('[data-chat]')) {
- e.preventDefault();
- e.stopPropagation();
- sendCardToChat(row);
- return;
- }
- selectCardModel(row);
- });
- root.appendChild(btn);
- }
- }
-
- function sendCardToChat(row) {
- if (!row?.name) {
- return;
- }
- selectCardModel(row);
- setView('chat');
- const kind = row.kind === 'checkpoint' ? 'checkpoint' : 'LoRA';
- const triggers = row.trigger ? ` Triggers: ${row.trigger}.` : '';
- if ($('sa_input')) {
- $('sa_input').value = `Используй ${kind} «${row.name}».${triggers} Учти карточку/triggers и предложи патч.`;
- $('sa_input').focus();
- }
- setStatus(`В чат → ${row.name}`);
- }
-
- function wireCardForm() {
- const sync = () => {
- if ($('sa_card_show_json')?.checked) {
- syncCardJsonFromForm();
- }
- };
- ['sa_card_triggers', 'sa_card_weight', 'sa_card_when', 'sa_card_avoid', 'sa_card_hint', 'sa_card_notes', 'sa_card_url']
- .forEach((id) => $(id)?.addEventListener('change', sync));
- $('sa_card_show_json')?.addEventListener('change', () => {
- const on = !!$('sa_card_show_json')?.checked;
- const ta = $('sa_card_json');
- if (ta) {
- ta.hidden = !on;
- if (on) {
- syncCardJsonFromForm();
- }
- }
- });
- $('sa_card_json')?.addEventListener('change', () => {
- if ($('sa_card_show_json')?.checked) {
- applyCardToForm(readCardDraft() || {});
- }
- });
- }
-
- function applyCardToForm(card) {
- card = card || {};
- const triggers = Array.isArray(card.triggers) ? card.triggers.join(', ') : (card.triggers || '');
- if ($('sa_card_triggers')) {
- $('sa_card_triggers').value = triggers;
- }
- if ($('sa_card_weight')) {
- $('sa_card_weight').value = card.weight != null ? card.weight : (state.cardsSelection?.kind === 'lora' ? 0.8 : 1);
- }
- if ($('sa_card_when')) {
- $('sa_card_when').value = card.when || '';
- }
- if ($('sa_card_avoid')) {
- $('sa_card_avoid').value = card.avoid || '';
- }
- if ($('sa_card_hint')) {
- $('sa_card_hint').value = card.prompt_hint || '';
- }
- if ($('sa_card_notes')) {
- $('sa_card_notes').value = card.notes || '';
- }
- if ($('sa_card_url')) {
- $('sa_card_url').value = card.civitai_url || '';
- }
- if ($('sa_card_json')) {
- $('sa_card_json').value = JSON.stringify(card, null, 2);
- }
- }
-
- function syncCardJsonFromForm() {
- const sel = state.cardsSelection || {};
- let base = {};
- try {
- base = JSON.parse($('sa_card_json')?.value || '{}');
- } catch (e) {
- base = {};
- }
- const triggers = String($('sa_card_triggers')?.value || '')
- .split(/[,;]/)
- .map((s) => s.trim())
- .filter(Boolean);
- const card = {
- ...base,
- kind: sel.kind || base.kind || 'lora',
- name: sel.name || base.name || '',
- triggers,
- weight: parseFloat($('sa_card_weight')?.value || '0.8') || 0.8,
- when: $('sa_card_when')?.value || '',
- avoid: $('sa_card_avoid')?.value || '',
- prompt_hint: $('sa_card_hint')?.value || '',
- notes: $('sa_card_notes')?.value || '',
- civitai_url: $('sa_card_url')?.value || '',
- version_id: base.version_id != null ? base.version_id : null,
- };
- if ($('sa_card_json')) {
- $('sa_card_json').value = JSON.stringify(card, null, 2);
- }
- return card;
- }
-
- function renderCardPreviews(urls) {
- const root = $('sa_card_previews');
- if (!root) {
- return;
- }
- const list = (urls || []).filter(Boolean).slice(0, 6);
- root.innerHTML = '';
- if (!list.length) {
- root.hidden = true;
- return;
- }
- root.hidden = false;
- for (const url of list) {
- const img = document.createElement('img');
- img.className = 'sa-card-thumb';
- img.src = url;
- img.alt = 'preview';
- img.title = 'Клик — на вкладку Refs';
- img.addEventListener('click', () => {
- setBoardTab('refs');
- addRefFromUrl(url);
- setStatus('Превью → Refs');
- });
- root.appendChild(img);
- }
- }
-
- function mergeCivitaiIntoCard(card, data) {
- const out = { ...(card || {}) };
- const civ = data?.civitai;
- if (!out.triggers?.length && data?.trigger_phrase) {
- out.triggers = [data.trigger_phrase];
- }
- if (civ) {
- const trained = civ.trainedWords || civ.trained_words;
- if ((!out.triggers || !out.triggers.length) && Array.isArray(trained) && trained.length) {
- out.triggers = trained.slice(0, 12);
- }
- if (!out.civitai_url) {
- const mid = civ.modelId || civ.model?.id || civ.model?.modelId;
- const vid = civ.id || data.version_id;
- if (mid && vid) {
- out.civitai_url = `https://civitai.red/models/${mid}?modelVersionId=${vid}`;
- } else if (vid) {
- out.civitai_url = `https://civitai.red/models/0?modelVersionId=${vid}`;
- }
- }
- if (out.version_id == null && (civ.id || data.version_id)) {
- out.version_id = civ.id || data.version_id;
- }
- if (!out.notes && civ.description) {
- out.notes = String(civ.description).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400);
- }
- }
- if (out.version_id == null && data?.version_id) {
- out.version_id = data.version_id;
- }
- return out;
- }
-
- function formatMetaStatus(data) {
- if (!data) {
- return 'Нет ответа';
- }
- if (data.error) {
- return String(data.error);
- }
- const parts = [];
- if (data.has_sidecar) {
- parts.push(`Сидикарь ✓ · version ${data.version_id || '?'}`);
- } else if (data.fetched) {
- parts.push(`Civitai ✓ · version ${data.version_id || '?'}`);
- } else {
- parts.push('Сидикаря нет');
- }
- const n = (data.example_urls || data.preview_urls || []).length;
- if (n) {
- parts.push(`${n} кадр${n === 1 ? '' : 'а'}`);
- }
- if (data.has_card) {
- parts.push('карточка Assistent ✓');
- } else {
- parts.push('карточки Assistent нет');
- }
- if (data.fetch_error) {
- parts.push(String(data.fetch_error));
- }
- return parts.join(' · ');
- }
-
- function applyCardMetaResponse(row, data, { preserveUser } = {}) {
- let card = data.card || {
- kind: row.kind,
- name: row.name,
- triggers: data.trigger_phrase ? [data.trigger_phrase] : [],
- weight: row.kind === 'lora' ? 0.8 : 1,
- when: '',
- avoid: '',
- prompt_hint: '',
- notes: '',
- civitai_url: '',
- version_id: data.version_id || null,
- };
- if (preserveUser) {
- const current = syncCardJsonFromForm();
- card = {
- ...mergeCivitaiIntoCard(card, data),
- when: current.when || card.when || '',
- avoid: current.avoid || card.avoid || '',
- prompt_hint: current.prompt_hint || card.prompt_hint || '',
- notes: current.notes || card.notes || '',
- };
- } else {
- card = mergeCivitaiIntoCard(card, data);
- }
- applyCardToForm(card);
- state.modelCards[`${row.kind}:${row.name}`] = card;
- const urls = [
- ...(data.preview_urls || []),
- ...(data.example_urls || []),
- ];
- renderCardPreviews(urls);
- const badge = $('sa_card_badge');
- if (badge) {
- badge.hidden = false;
- badge.textContent = data.has_card ? 'card ✓' : (data.has_sidecar || data.fetched ? 'meta ✓' : 'нет меты');
- }
- setCardStatus(formatMetaStatus(data));
- }
-
- function selectCardModel(row) {
- state.cardsSelection = row;
- renderCardsList();
- if ($('sa_card_title')) {
- $('sa_card_title').textContent = row.title || row.name;
- }
- const badge = $('sa_card_badge');
- if (badge) {
- badge.hidden = false;
- badge.textContent = '…';
- }
- setCardStatus('Читаю локальную мету…');
- genericRequest(
- 'AssistentGetCardMeta',
- { kind: row.kind, name: row.name, fetch: false },
- (data) => applyCardMetaResponse(row, data || {}),
- 0,
- (err) => setCardStatus(String(err || 'Ошибка загрузки')),
- );
- }
-
- function fetchCardMetaLive() {
- const row = state.cardsSelection;
- if (!row) {
- setCardStatus('Выбери модель');
- return;
- }
- setCardStatus('Сидикаря нет · ищу по SHA…');
- genericRequest(
- 'AssistentGetCardMeta',
- { kind: row.kind, name: row.name, fetch: true },
- (data) => applyCardMetaResponse(row, data || {}, { preserveUser: true }),
- 0,
- (err) => setCardStatus(String(err || 'Civitai: ошибка запроса')),
- );
- }
-
- async function addRefFromUrl(url) {
- if (!url) {
- return;
- }
- addRefSlot({ src: url, select: false });
- }
-
- function readCardDraft() {
- const fromForm = syncCardJsonFromForm();
- if ($('sa_card_show_json')?.checked) {
- const raw = $('sa_card_json')?.value || '';
- try {
- return JSON.parse(raw);
- } catch (e) {
- setCardStatus('Невалидный JSON');
- return null;
- }
- }
- return fromForm;
- }
-
- function saveCurrentCard({ enqueue } = {}) {
- const sel = state.cardsSelection;
- if (!sel) {
- setCardStatus('Выбери модель');
- return;
- }
- const card = readCardDraft();
- if (!card) {
- return;
- }
- card.kind = card.kind || sel.kind;
- card.name = card.name || sel.name;
- setCardStatus('Сохраняю…');
- genericRequest(
- 'AssistentSaveCard',
- { kind: sel.kind, name: sel.name, card, enqueue_wanted: !!enqueue },
- (data) => {
- if (data.error) {
- setCardStatus(data.error);
- return;
- }
- state.modelCards[`${sel.kind}:${sel.name}`] = card;
- setCardStatus(data.installed
- ? `Карточка Assistent сохранена · ${data.path}`
- : `Черновик + wanted · ${data.path}`);
- refreshInventory(() => renderCardsList());
- if (enqueue || !data.installed) {
- refreshWantedQueue();
- }
- },
- 0,
- (err) => setCardStatus(String(err || 'Ошибка сохранения')),
- );
- }
-
- function enqueueWantedOnly() {
- const sel = state.cardsSelection;
- const card = readCardDraft() || {};
- if (!sel && !card.civitai_url) {
- setCardStatus('Нужна модель или civitai_url');
- return;
- }
- genericRequest(
- 'AssistentEnqueueWanted',
- {
- kind: (card.kind || sel?.kind || 'lora'),
- url: card.civitai_url || '',
- version_id: card.version_id || 0,
- title: card.name || sel?.name || '',
- card,
- },
- (data) => {
- setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
- refreshWantedQueue();
- },
- 0,
- (err) => setCardStatus(String(err || 'Ошибка enqueue')),
- );
- }
-
- function shortLoraName(name) {
- const s = String(name || '');
- const base = s.split(/[/\\]/).pop() || s;
- return base.replace(/\.safetensors$/i, '').slice(0, 28);
- }
-
- function renderLoraChips() {
- const root = $('sa_lora_chips');
- if (!root) {
- return;
- }
- root.innerHTML = '';
- let selected = [];
- try {
- if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
- selected = loraHelper.selected.map((l) => ({
- name: l.name || l,
- weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
- }));
- }
- } catch (e) { /* ignore */ }
- for (const l of selected) {
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.className = 'sa-lora-chip';
- btn.title = `${l.name} ×${l.weight} — клик снять`;
- btn.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
- btn.addEventListener('click', () => {
- try {
- if (typeof loraHelper !== 'undefined' && typeof loraHelper.removeLora === 'function') {
- loraHelper.removeLora(l.name);
- } else if (loraHelper?.selected) {
- loraHelper.selected = loraHelper.selected.filter((x) => (x.name || x) !== l.name);
- if (typeof loraHelper.rebuildUI === 'function') {
- loraHelper.rebuildUI();
- }
- }
- } catch (e) { /* ignore */ }
- renderLoraChips();
- });
- root.appendChild(btn);
- }
- const add = document.createElement('button');
- add.type = 'button';
- add.className = 'sa-lora-chip sa-lora-add';
- add.textContent = '+ LoRA';
- add.title = 'Добавить из inventory';
- add.addEventListener('click', (e) => {
- e.stopPropagation();
- openLoraPicker(add);
- });
- root.appendChild(add);
- }
-
- function openLoraPicker(anchor) {
- document.querySelectorAll('.sa-lora-picker').forEach((n) => n.remove());
- const picker = document.createElement('div');
- picker.className = 'sa-lora-picker';
- const inv = (state.inventory?.loras || []).slice().sort((a, b) => (b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0));
- const filter = document.createElement('input');
- filter.type = 'search';
- filter.placeholder = 'Фильтр LoRA…';
- filter.style.cssText = 'width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem;';
- picker.appendChild(filter);
- const list = document.createElement('div');
- picker.appendChild(list);
- const draw = () => {
- list.innerHTML = '';
- const q = filter.value.trim().toLowerCase();
- let n = 0;
- for (const l of inv) {
- const name = l.name || '';
- if (q && !String(name).toLowerCase().includes(q) && !String(l.title || '').toLowerCase().includes(q)) {
- continue;
- }
- const btn = document.createElement('button');
- btn.type = 'button';
- btn.textContent = `${shortLoraName(name)}${l.krea_likely ? ' · krea' : ''}`;
- btn.title = name;
- btn.addEventListener('click', async () => {
- await applyPatch({
- loras: [
- ...((() => {
- try {
- return (loraHelper?.selected || []).map((x) => ({
- name: x.name || x,
- weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[x.name || x]) || 1,
- }));
- } catch (e) {
- return [];
- }
- })()),
- { name, weight: l.default_weight ? parseFloat(l.default_weight) : 0.8, triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []) },
- ],
- }, 'loras');
- picker.remove();
- renderLoraChips();
- });
- list.appendChild(btn);
- if (++n >= 40) {
- break;
- }
- }
- if (!n) {
- list.innerHTML = 'Нет LoRA
';
- }
- };
- filter.addEventListener('input', draw);
- draw();
- const composer = $('sa_composer') || document.body;
- composer.style.position = composer.style.position || 'relative';
- composer.appendChild(picker);
- const onDoc = (ev) => {
- if (!picker.contains(ev.target) && ev.target !== anchor) {
- picker.remove();
- document.removeEventListener('mousedown', onDoc);
- }
- };
- setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
- filter.focus();
- }
-
- function loadTasteFromServer() {
- if (typeof genericRequest !== 'function') {
- return;
- }
- genericRequest(
- 'AssistentGetTaste',
- {},
- (data) => {
- const remote = data?.taste;
- if (!remote || typeof remote !== 'object') {
- return;
- }
- const remoteUpdated = remote.updated || 0;
- const localUpdated = state.taste?.updated || 0;
- // sqlite taste is the source of truth; localStorage only wins when it is strictly newer.
- const localEmpty = !localUpdated
- && !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
- if (localEmpty || remoteUpdated >= localUpdated) {
- state.taste = {
- styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
- likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
- avoid: Array.isArray(remote.avoid) ? remote.avoid.slice(0, 12) : [],
- notes: String(remote.notes || '').slice(0, 400),
- updated: remoteUpdated || Date.now(),
- };
- saveTasteLocalOnly();
- }
- },
- 0,
- () => {},
- );
- }
-
- function saveTasteLocalOnly() {
- try {
- localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
- } catch (e) { /* ignore */ }
- }
-
- function saveTasteToServerDebounced() {
- if (state.tasteSaveTimer) {
- clearTimeout(state.tasteSaveTimer);
- }
- state.tasteSaveTimer = setTimeout(() => {
- if (typeof genericRequest !== 'function') {
- return;
- }
- genericRequest(
- 'AssistentSaveTaste',
- { taste: state.taste || {} },
- () => {},
- 0,
- () => {},
- );
- }, 800);
- }
-
- async function generateCardWithAssistent() {
- const sel = state.cardsSelection;
- if (!sel) {
- setCardStatus('Выбери модель');
- return;
- }
- if (state.busy) {
- setCardStatus('Чат занят');
- return;
- }
- setPackValue('catalog_card', { flash: true });
- setView('chat');
- const meta = await new Promise((resolve) => {
- genericRequest(
- 'AssistentGetCardMeta',
- { kind: sel.kind, name: sel.name },
- (data) => resolve(data),
- 0,
- () => resolve(null),
- );
- });
- const forced = `Write a recommendation card for this ${sel.kind}: ${sel.name}. Use metadata/triggers only; output one JSON card.`;
- await sendChat({
- forcedUserText: forced,
- skipSlash: true,
- skipAutoPack: true,
- fromCards: true,
- cardTarget: {
- kind: sel.kind,
- name: sel.name,
- meta,
- },
- });
- }
-
- function inventoryIsStale(maxAgeMs = 20000) {
- if (!state.inventoryFetchedAt) {
- return true;
- }
- return (Date.now() - state.inventoryFetchedAt) > maxAgeMs;
- }
-
- async function ensureFreshInventory({ forceRescan } = {}) {
- const rescan = forceRescan || inventoryIsStale(20000);
- await refreshInventoryAsync({ rescan });
- }
-
- function triggerSwarmModelRefresh(done) {
- if (typeof genericRequest !== 'function') {
- if (done) {
- done();
- }
- return;
- }
- genericRequest(
- 'TriggerRefresh',
- { strong: true },
- () => {
- if (done) {
- done();
- }
- },
- 0,
- () => {
- if (done) {
- done();
- }
- },
- );
- }
-
- async function handleReplySideEffects(reply, civitaiResults, opts = {}) {
- const { fromAutoCritique, fromVisionHop, fromCards, fromDebug } = opts;
- if (fromCards) {
- const card = extractCardJson(reply);
- if (card) {
- if ($('sa_card_json')) {
- $('sa_card_json').value = JSON.stringify(card, null, 2);
- }
- if (opts.fromDownload || opts.cardTarget) {
- const kind = card.kind || opts.cardTarget?.kind || 'lora';
- const name = card.name || opts.cardTarget?.name;
- if (name && typeof genericRequest === 'function') {
- genericRequest(
- 'AssistentSaveCard',
- { kind, name, card, enqueue_wanted: false },
- (data) => {
- if (data?.path) {
- state.modelCards[`${kind}:${name}`] = card;
- setCardStatus(data.installed ? `Card saved → ${data.path}` : `Card draft → ${data.path}`);
- setStatus(`Card saved for ${name}`);
- }
- },
- 0,
- () => setCardStatus('Card draft ready — Save manually'),
- );
- }
- } else {
- setView('cards');
- setCardStatus('Draft from Assistent — review & Save');
- }
- }
- return;
- }
- const { patch } = extractPatch(reply);
- let effective = patch;
- if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug) {
- const aspect = parseAspectFromUserText(opts.userText || '');
- if (aspect && (replyMissingJsonPatch(reply) || isSameButAspectRequest(opts.userText || ''))) {
- effective = { aspect, actions: ['generate'] };
- if (state.lastPatch?.prompt) {
- effective.prompt = state.lastPatch.prompt;
- }
- appendSystemNote(`Патч пустой — применил aspect ${aspect} сам.`);
- }
- }
- if (effective) {
- rememberLastPatch(effective);
- }
- if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
- doInterruptNow();
- }
- if (civitaiResults && civitaiResults.length && $('sa_auto_download')?.checked) {
- const pick = civitaiResults.find((r) => !r.already_installed && r.download_url && r.krea_likely)
- || civitaiResults.find((r) => !r.already_installed && r.download_url);
- if (pick) {
- downloadCivitaiLoRA(pick, null);
- }
- }
- const suppressGen = !fromVisionHop && !fromAutoCritique && userAsksNoGenerate(opts.userText || '');
- if (suppressGen && effective) {
- effective = stripLookAt(stripGenerateAction(effective));
- state.pendingSilentGen = false;
- if (effective) {
- rememberLastPatch(effective);
- }
- }
- if (effective && !fromVisionHop && !fromAutoCritique && !suppressGen) {
- const hopped = await maybeVisionHop(effective, opts.attachedSlotIds || []);
- if (hopped) {
- return;
- }
- }
- if (fromDebug) {
- // Q&A only — never apply patches / generate from a debug explanation turn.
- state.pendingSilentGen = false;
- return;
- }
- const wantsGen = !suppressGen && !!(opts.userWantsGenerate || state.pendingSilentGen
- || (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate')));
- const doApply = !!(effective && (wantsGen || $('sa_auto_apply')?.checked));
- if (doApply) {
- if (wantsGen) {
- startBusyUi('silent_gen');
- } else {
- setBusyPhase('applying');
- }
- await applyPatch(effective, 'all');
- syncLiveParamsBar();
- updateTasteFromPatch(effective, opts.userText || '');
- // Auto-Generate must not fire on «запомни / шаблон» turns — even if the model
- // echoed a prompt patch or sneaked actions:["generate"].
- if (!fromAutoCritique && !suppressGen && (wantsGen || $('sa_auto_generate')?.checked)) {
- const src = await runGenerateFromPatch(
- { ...effective, actions: Array.isArray(effective.actions) ? effective.actions : ['generate'] },
- { force: wantsGen },
- );
- if (src) {
- await maybeAutoCritique(src);
- await maybeAutoVisionLook(src);
- }
- } else if (!state.generating) {
- stopBusyUi(suppressGen ? 'Запомнил · без Generate' : (wantsGen ? 'Применено' : ''));
- }
- } else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) {
- setStatus('Ответ без JSON-патча — ничего не применено');
- }
- state.pendingSilentGen = false;
- }
-
- async function applyQuickPatch(patch, note) {
- const withActions = { ...patch };
- if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) {
- withActions.actions = ['generate'];
- }
- const prevIntent = state.lastUserParamIntent;
- state.lastUserParamIntent = true;
- await applyPatch(withActions, 'all');
- state.lastUserParamIntent = prevIntent;
- setStatus(note || 'Applied');
- if ($('sa_auto_generate')?.checked) {
- await runGenerateFromPatch(withActions);
- }
- syncChipHighlight();
- }
-
- function syncChipHighlight() {
- const bar = $('sa_chips');
- if (!bar) {
- return;
- }
- const cur = guessAspectFromSize(val('input_width'), val('input_height'));
- const seed = val('input_seed');
- bar.querySelectorAll('[data-aspect]').forEach((btn) => {
- btn.classList.toggle('sa-chip-active', btn.getAttribute('data-aspect') === cur);
- });
- bar.querySelectorAll('[data-seed]').forEach((btn) => {
- const mode = btn.getAttribute('data-seed');
- const active = (mode === 'lock' && seed && seed !== '-1') || (mode === 'random' && (!seed || seed === '-1'));
- btn.classList.toggle('sa-chip-active', active);
- });
- }
-
- function appendSystemNote(text) {
- const box = $('sa_messages');
- if (!box) {
- return;
- }
- hideChatEmpty();
- const div = document.createElement('div');
- div.className = 'sa-msg assistant sa-system-note';
- div.textContent = text;
- box.appendChild(div);
- scrollMessagesToBottom();
- }
-
- function clipDebug(s, max) {
- const t = String(s || '').replace(/\s+/g, ' ').trim();
- if (!t) {
- return '—';
- }
- return t.length > max ? `${t.slice(0, max)}…` : t;
- }
-
- function formatDebugLoras(list) {
- if (!Array.isArray(list) || !list.length) {
- return 'нет';
- }
- return list.slice(0, 8).map((l) => {
- const name = l?.name || l;
- const w = l?.weight != null ? `@${l.weight}` : '';
- return `${name}${w}`;
- }).join(', ');
- }
-
- function buildDebugSummary() {
- const persona = $('sa_persona')?.value || 'neutral';
- const pack = $('sa_pack')?.value || defaultPackId();
- const chatModel = $('sa_model')?.value || '—';
- const embed = $('sa_embed_model')?.value || state.preferredEmbed || '—';
- const profile = detectKreaProfileName();
- const defaults = mergedGenerationDefaults(profile);
- const session = state.sessionExact || {};
- const exactGen = state.exact?.generation || state.config?.exact?.generation || {};
- const ctx = (() => {
- try {
- return collectLiveContext();
- } catch (e) {
- return {};
- }
- })();
- const aspect = guessAspectFromSize(ctx.width, ctx.height) || defaults.aspect || '—';
- const why = [];
- if (Object.keys(session).length) {
- why.push(`session_exact перекрывает Exact: ${Object.keys(session).join(', ')}`);
- } else {
- why.push('session_exact пуст — params из Exact + профиль чекпоинта');
- }
- why.push(`профиль чекпоинта: ${profile} (имя/title → turbo|raw)`);
- if (persona === 'cinema' || state.exact?.generation?.aspect) {
- why.push(`persona/exact aspect: ${state.exact?.generation?.aspect || exactGen.aspect || '—'}`);
- }
- if (state.lastPatch) {
- const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null && k !== 'notes');
- why.push(`последний патч задал: ${keys.slice(0, 12).join(', ')}`);
- } else {
- why.push('последнего патча Assistent ещё нет');
- }
- why.push('приоритет: user → About the user → session_exact → exact(+persona) → live UI → craft memory_hits');
-
- const lines = [
- '### Debug Assistent',
- `persona=${persona} · pack=${pack}`,
- `chat=${chatModel} · embed=${embed}`,
- `skills=${(state.enabledSkills || []).join(',') || '—'}`,
- `auto: apply=${!!$('sa_auto_apply')?.checked} gen=${!!$('sa_auto_generate')?.checked} vision=${!!$('sa_auto_vision')?.checked} critique=${!!$('sa_auto_critique')?.checked}`,
- '',
- 'Live SwarmUI:',
- ` ckpt=${ctx.checkpoint?.name || '—'} · krea_profile=${ctx.krea_profile || profile}`,
- ` ${ctx.width || '?'}×${ctx.height || '?'} (${aspect}) · steps=${ctx.steps ?? '—'} · cfg=${ctx.cfg ?? '—'} · sigma=${ctx.sigma_shift ?? '—'} · seed=${ctx.seed ?? '—'} · batch=${ctx.batch ?? '—'}`,
- ` loras: ${formatDebugLoras(ctx.selected_loras || ctx.enabled_loras)}`,
- ` available_loras=${(ctx.available_loras || []).length}${ctx.available_loras_truncated ? ` truncated/${ctx.available_loras_total || '?'}` : ''}`,
- ` prompt: ${clipDebug(ctx.prompt, 220)}`,
- ` negative: ${clipDebug(ctx.negative, 120)}`,
- ` init=${!!ctx.has_init_image} mask=${!!ctx.has_mask_image} prompt_images=${ctx.prompt_image_count || 0}`,
- ` has_vision_image=${!!ctx.has_vision_image} · images_in_request=${!!ctx.images_in_request} · vision_ready=${visionReadySlots().length}`,
- ` context_json_chars≈${JSON.stringify(ctx).length} · last_system_chars=${state.lastSystemChars || '—'} · last_context_chars=${state.lastContextChars || '—'}`,
- state.lastSystemLayers
- ? ` system_layers: ${Object.entries(state.lastSystemLayers).map(([k, v]) => `${k}=${v}`).join(' · ')}`
- : ' system_layers: — (отправь сообщение, чтобы заполнить)',
- '',
- 'Exact defaults (merged):',
- ` generation=${JSON.stringify(exactGen)}`,
- ` effective=${JSON.stringify({
- steps: defaults.steps,
- cfg: defaults.cfg,
- sigma_shift: defaults.sigma_shift,
- aspect: defaults.aspect,
- images: defaults.images,
- profile: defaults.profile,
- })}`,
- ` session_exact=${Object.keys(session).length ? JSON.stringify(session) : '{}'}`,
- '',
- 'Почему так:',
- ...why.map((w) => ` · ${w}`),
- ];
- if (state.lastPatch) {
- lines.push('', `last_patch: ${clipDebug(JSON.stringify(state.lastPatch), 360)}`);
- }
- return lines.join('\n');
- }
-
- async function handleSlashCommand(raw) {
- const text = String(raw || '').trim();
- if (!text.startsWith('/')) {
- return false;
- }
- const parts = text.slice(1).split(/\s+/);
- const cmd = (parts[0] || '').toLowerCase();
- const arg = parts.slice(1).join(' ').trim();
-
- if (cmd === 'help' || cmd === '?') {
- appendSystemNote(HELP_TEXT);
- setStatus('/help');
- return true;
- }
- if (cmd === 'new' || cmd === 'newchat') {
- await startNewChat({ saveCurrent: true });
- return true;
- }
- if (cmd === 'history' || cmd === 'chats' || cmd === 'sessions') {
- setChatsPanelOpen(true);
- setStatus('/history');
- return true;
- }
- if (cmd === 'debug' || cmd === 'dbg' || cmd === 'why') {
- const dump = buildDebugSummary();
- appendSystemNote(dump);
- const argL = String(arg || '').toLowerCase().trim();
- const wantLlm = cmd === 'why'
- || /^(ask|llm|explain|поясни|почему|модель)(\s|$)/i.test(argL);
- if (wantLlm) {
- setStatus('/debug ask…');
- await sendChat({
- forcedUserText:
- 'Отладка Assistent. Ниже факты UI (уже собраны клиентом). ' +
- 'Кратко своими словами (5–10 строк, язык пользователя): какие промпт/params сейчас, ' +
- 'что из Exact vs session_exact vs live, что сделал последний патч и почему так логично. ' +
- 'Без JSON patch, без generate, без look_at.\n\n' + dump,
- skipSlash: true,
- skipAutoPack: true,
- fromDebug: true,
- });
- } else {
- setStatus('/debug');
- }
- return true;
- }
- if (cmd === 'gen' || cmd === 'generate') {
- const prev = findCurrentGenerateSrc();
- startBusyUi('generating');
- state.generating = true;
- setInterruptVisible(true);
- if (!triggerGenerate()) {
- state.generating = false;
- stopBusyUi('Could not start Generate');
- return true;
- }
- const src = await waitForNewImage(prev);
- state.generating = false;
- setInterruptVisible(state.busy);
- if (src) {
- const gen = generateSlot();
- if (gen) {
- gen.src = src;
- renderBoard();
- }
- stopBusyUi('Generate done');
- } else {
- stopBusyUi('Generate finished');
- }
- return true;
- }
- if (cmd === 'look') {
- const id = normalizeSlotId(arg || GEN_ID) || GEN_ID;
- const slot = slotById(id);
- if (!slot) {
- setStatus(`Неизвестный слот: ${arg || GEN_ID}`);
- return true;
- }
- if (slot.type !== 'generate') {
- setBoardTab('refs');
- } else {
- setBoardTab('generate');
- }
- if (!slot.src && id === GEN_ID) {
- const src = findCurrentGenerateSrc();
- if (src) {
- slot.src = src;
- }
- }
- if (!slot.src) {
- setStatus(`Slot ${id} is empty`);
- return true;
- }
- slot.attach = true;
- renderBoard();
- if ($('sa_input')) {
- $('sa_input').value = `Look at ${id} and describe what you see.`;
- }
- setPackValue('critique_image', { flash: true });
- await sendChat({ forceSlotIds: [id], skipAutoPack: true });
- return true;
- }
- if (cmd === 'init') {
- const src = selectedSrc() || findCurrentGenerateSrc();
- if (!src) {
- setStatus('No image for Init');
- return true;
- }
- await setInitFromSrc(src);
- setPackValue('inpaint_edit', { flash: true });
- return true;
- }
- if (cmd === 'mask') {
- const src = selectedSrc();
- if (!src) {
- setStatus('Select a window with a mask image');
- return true;
- }
- await setMaskFromSrc(src);
- setPackValue('inpaint_edit', { flash: true });
- return true;
- }
- if (cmd === 'clear') {
- clearInitAndMask();
- return true;
- }
- if (cmd === 'interrupt' || cmd === 'stop') {
- doInterruptNow();
- clearInFlightUi({ status: 'Прервано' });
- return true;
- }
- if (cmd === 'aspect') {
- const key = normalizeAspect(arg);
- if (!key) {
- setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`);
- return true;
- }
- await applyQuickPatch({ aspect: key, actions: ['generate'] }, `Aspect ${key}`);
- return true;
- }
- if (cmd === 'seed') {
- const mode = (arg || 'random').toLowerCase();
- if (mode === 'lock' || mode === 'keep') {
- await applyQuickPatch({ lock_seed: true }, 'Seed locked');
- } else {
- await applyQuickPatch({ seed: -1, vary: true, actions: ['generate'] }, 'Seed random');
- }
- return true;
- }
- if (cmd === 'vary') {
- await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)');
- return true;
- }
- if (cmd === 'inventory' || cmd === 'inv') {
- setStatus('Rescanning models…');
- triggerSwarmModelRefresh(async () => {
- await refreshInventoryAsync({ rescan: true });
- const n = state.inventory?.loras?.length || 0;
- const ck = state.inventory?.checkpoints?.length || 0;
- appendSystemNote(`Inventory refreshed: ${n} LoRAs, ${ck} checkpoints.`);
- setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts (rescanned)`);
- });
- return true;
- }
- if (cmd === 'pack') {
- if (!setPackValue(arg, { flash: true, user: true })) {
- setStatus('Pack: write|critique|compose|params|inpaint|describe');
- } else {
- setStatus(`Pack → ${$('sa_pack')?.value}`);
- }
- return true;
- }
- if (cmd === 'остынь' || cmd === 'ostyn' || cmd === 'cool' || cmd === 'cooldown') {
- coolDownHorny();
- return true;
- }
- if (cmd === 'horny-game' || cmd === 'hornygame' || cmd === 'horny_game') {
- await startHornyGame();
- return true;
- }
- if (cmd === 'civitai') {
- if (!arg) {
- setStatus('/civitai ');
- return true;
- }
- if ($('sa_input')) {
- $('sa_input').value = `Find a Krea 2 LoRA for: ${arg}`;
- }
- setPackValue(defaultPackId(), { flash: true });
- await sendChat({
- skipAutoPack: true,
- forcedUserText: `Search Civitai for Krea-compatible LoRA: ${arg}. Prefer actions search_civitai.`,
- });
- return true;
- }
- if (cmd === 'persona') {
- const sub = (parts[1] || 'new').toLowerCase();
- const rest = parts.slice(2).join(' ').trim();
- setPackValue('author_persona', { flash: true, user: true });
- if (sub === 'save') {
- await sendChat({
- skipAutoPack: true,
- forcedUserText:
- 'Сохрани согласованный черновик личности сейчас (persona_clone / persona_write). Не удаляй личности.',
- });
- return true;
- }
- const fromId = sub === 'clone' && rest
- ? rest.split(/\s+/)[0]
- : ($('sa_persona')?.value || 'neutral');
- await sendChat({
- skipAutoPack: true,
- forcedUserText:
- `Начни интервью author_persona: клон с источника «${fromId}». ` +
- 'Спрашивай по полкам группами. Не пиши на диск, пока мало ответов. Не удаляй личности.',
- });
- return true;
- }
-
- appendSystemNote(`Unknown command /${cmd}.\n\n${HELP_TEXT}`);
- setStatus(`Unknown /${cmd}`);
- return true;
- }
-
- async function maybeVisionHop(patch, attachedSlotIds) {
- const ids = lookAtIdsFromPatch(patch);
- if (!ids.length || state.visionHopUsed) {
- return false;
- }
- scrubPreviewFromGenerateSlot();
- const have = ids.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
- if (!have.length) {
- const genSrc = findCurrentGenerateSrc();
- if (ids.includes(GEN_ID) && genSrc) {
- const gen = generateSlot();
- if (gen) {
- gen.src = genSrc;
- have.push(gen);
- }
- }
- }
- if (!have.length) {
- setStatus('look_at: нет реального кадра (превью модели пропущено)');
- return false;
- }
- const already = new Set(attachedSlotIds || []);
- const need = have.filter((s) => !already.has(s.id));
- if (!need.length) {
- return false;
- }
- state.visionHopUsed = true;
- for (const s of need) {
- s.attach = true;
- }
- renderBoard();
- if ($('sa_input')) {
- $('sa_input').value = `Look at board slots: ${need.map((s) => s.id).join(', ')}. Continue using these images.`;
- }
- setStatus(`Vision hop ← ${need.map((s) => s.label).join(', ')}`);
- await sendChat({ fromVisionHop: true, forceSlotIds: need.map((s) => s.id) });
- return true;
- }
-
- async function sendChat(opts = {}) {
- if ((state.busy || state.generating) && !opts.fromVisionHop && !opts.fromAutoCritique) {
- return;
- }
- const rawInput = ($('sa_input')?.value || '').trim();
- const text = (opts.forcedUserText || rawInput).trim();
- if (!text) {
- return;
- }
- if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
- state.lastUserParamIntent = userTextMentionsParams(text);
- state.lastUserControlIntent = userTextMentionsControls(text);
- state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text);
- if (userAsksNoGenerate(text)) {
- state.pendingSilentGen = false;
- }
- }
-
- if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) {
- if (rawInput.startsWith('/')) {
- if ($('sa_input')) {
- $('sa_input').value = '';
- }
- const handled = await handleSlashCommand(rawInput);
- if (handled) {
- return;
- }
- }
- }
-
- // «такую же, только 9 на 16» — apply aspect + Generate without waiting for an empty LLM critique.
- if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug
- && !opts.fromCards && isSameButAspectRequest(text)) {
- const aspect = parseAspectFromUserText(text);
- if (aspect) {
- if ($('sa_input')) {
- $('sa_input').value = '';
- }
- appendMessage('user', text);
- state.history.push({ role: 'user', content: text });
- persistHistory();
- restoreDefaultPackAfterHop();
- const patch = { aspect, actions: ['generate'] };
- if (state.lastPatch?.prompt) {
- patch.prompt = state.lastPatch.prompt;
- }
- if (Array.isArray(state.lastPatch?.loras) && state.lastPatch.loras.length) {
- patch.loras = state.lastPatch.loras;
- }
- appendSystemNote(`Ставлю ${aspect} и Generate (тот же промпт) — без повторной критики.`);
- await applyQuickPatch(patch, `Aspect ${aspect}`);
- return;
- }
- }
-
- if (!updateGate()) {
- setStatus('Выбери модель Krea 2');
- return;
- }
-
- if (!opts.skipAutoPack && !opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromCards) {
- const guessed = autoSelectPack(text);
- if (guessed) {
- setPackValue(guessed, { flash: true });
- }
- }
-
- // Cards mode must not be overridden by auto-pack; keep catalog_card.
- if (opts.fromCards || state.view === 'cards') {
- setPackValue('catalog_card', { flash: false });
- }
-
- const pack = $('sa_pack')?.value || defaultPackId();
- const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
- const model = $('sa_model')?.value;
- if (!model) {
- setStatus('Выбери модель Ollama в ⚙');
- refreshModels();
- return;
- }
-
- const chatEpoch = bumpChatEpoch();
- state.busy = true;
- // If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here.
- state.llmParked = false;
- setInterruptVisible(true);
- if (state.expectColdLoad && !opts.fromVisionHop && !opts.fromAutoCritique) {
- startBusyUi('warming');
- setStatus('Возвращаю LLM в GPU…');
- try {
- await warmLlm({ force: true });
- } catch (e) {
- console.warn('Assistent warm before send', e);
- }
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- }
- startBusyUi(state.expectColdLoad ? 'loading' : 'thinking');
- saveSettings();
-
- // Always pull latest LoRA/checkpoint list before the LLM sees context
- // (rescans disk when inventory is older than ~20s or after downloads).
- setStatus('Обновляю inventory…');
- try {
- await ensureFreshInventory({ forceRescan: !!opts.fromDownload });
- await prefetchActiveModelCards();
- } catch (e) {
- console.warn('Assistent inventory refresh', e);
- }
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
-
- if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop) {
- state.critiqueHopUsed = false;
- state.visionHopUsed = false;
- }
-
- let wantedIds = (opts.forceSlotIds || []).map(normalizeSlotId).filter(Boolean);
- // JPEG only on look_at / vision hop — never auto-attach every board ref to ordinary chat.
- const sendVision = !!(opts.fromVisionHop || (wantedIds.length && opts.forceSlotIds));
- if (!sendVision) {
- wantedIds = [];
- }
- const visionSlots = wantedIds.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
- let images = null;
- if (visionSlots.length) {
- startBusyUi('encoding');
- setStatus('Кодирую изображение…');
- images = [];
- for (const slot of visionSlots) {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- const b64 = await imageToBase64ForOllama(slot.src);
- if (b64) {
- images.push(b64);
- }
- }
- if (!images.length) {
- images = null;
- }
- }
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
-
- const msgMeta = {
- persona: currentPersonaInfo(),
- pack,
- silentPatch: !!state.pendingSilentGen,
- };
- state.history.push({ role: 'user', content: text });
- if (state.pendingPersonaNote) {
- state.history.push({ role: 'user', content: state.pendingPersonaNote, systemish: true });
- state.pendingPersonaNote = null;
- }
- appendMessage('user', text);
- $('sa_input').value = '';
- persistHistory();
-
- const context = collectLiveContext();
- // has_vision_image = board has a real frame (even when JPEG is not in this request).
- // images_in_request = JPEG bytes are attached to the last user message this turn.
- context.has_vision_image = visionReadySlots().length > 0;
- context.images_in_request = !!(images && images.length);
- context.attached_slot_ids = attachableSlots().map((s) => s.id);
- context.vision_slot_ids = visionSlots.map((s) => s.id);
- context.persona = persona;
- if (opts.cardTarget) {
- context.card_target = opts.cardTarget;
- }
- if (opts.fromCards || pack === 'catalog_card') {
- context.auto_apply = false;
- context.auto_generate = false;
- }
- await prefetchActiveModelCards();
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- // Refresh cards into context after prefetch
- const refreshed = collectLiveContext();
- context.model_cards = refreshed.model_cards;
- const messages = state.history.slice(-historyMessageLimit()).map((m) => {
- let content = String(m.content || '');
- if (m.role === 'assistant') {
- content = stripJsonFencesForHistory(content);
- }
- return { role: m.role, content: content.slice(0, 4000) };
- });
- if (images && messages.length) {
- messages[messages.length - 1].images = images;
- }
-
- startBusyUi('thinking');
-
- const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
- // Flat fields: SwarmUI JObject params receive the whole request body.
- const payload = {
- baseUrl,
- model,
- pack,
- persona,
- includeBase: true,
- messages,
- context_json: JSON.stringify(context),
- skills: state.enabledSkills || [],
- embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
- };
-
- const finishOk = async (reply, civitaiResults, meta = {}) => {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- if (meta.system_chars != null) {
- state.lastSystemChars = Number(meta.system_chars) || 0;
- }
- if (meta.system_layers && typeof meta.system_layers === 'object') {
- state.lastSystemLayers = meta.system_layers;
- }
- try {
- state.lastContextChars = (context && JSON.stringify(context).length) || 0;
- } catch (e) {
- state.lastContextChars = 0;
- }
- const prose = extractPatch(reply).prose || reply;
- state.history.push({ role: 'assistant', content: prose, persona, pack });
- persistHistory();
- setBusyPhase(state.pendingSilentGen || userAsksGenerate(text) ? '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;
- }
- // Keep busy while silent Apply+Generate is still running (generating flag).
- if (!state.generating) {
- state.busy = false;
- setInterruptVisible(false);
- stopBusyUi('Готово');
- } else {
- state.busy = false;
- setInterruptVisible(true);
- }
- }
- };
-
- const finishErr = (msg) => {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- state.busy = false;
- setInterruptVisible(state.generating);
- stopBusyUi(msg);
- if (state.streamEl) {
- state.streamEl.classList.remove('sa-streaming', 'sa-typing');
- state.streamEl.classList.add('error');
- setAssistantBody(state.streamEl, msg);
- state.streamEl = null;
- state.streamMeta = null;
- } else {
- appendMessage('error', msg);
- }
- };
-
- if (typeof makeWSRequest === 'function') {
- beginStreamMessage(msgMeta);
- makeWSRequest(
- 'AssistentChatWS',
- payload,
- (data) => {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- if (data.phase === 'waiting_ollama') {
- // Server always emits this before /api/chat — not proof of a cold load.
- setBusyPhase(state.expectColdLoad ? 'loading' : 'waiting');
- const label = state.streamEl?.querySelector('.sa-typing-label');
- if (label) {
- label.textContent = state.expectColdLoad
- ? `Загружаю ${modelShort(model)} в GPU…`
- : 'Думаю…';
- }
- return;
- }
- if (data.error) {
- finishErr(String(data.error));
- return;
- }
- if (data.clear_stream) {
- if (state.streamEl) {
- state.streamEl.classList.add('sa-typing');
- const body = state.streamEl.querySelector('.sa-msg-body') || state.streamEl;
- body.innerHTML = 'Уточняю… ';
- }
- setBusyPhase('refining');
- return;
- }
- if (data.delta) {
- appendStreamDelta(data.delta);
- return;
- }
- if (data.done || data.reply != null) {
- const reply = data.reply || (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || '';
- const civitai = data.civitai_results || [];
- finalizeStreamMessage(reply, civitai);
- finishOk(reply, civitai, { system_chars: data.system_chars, system_layers: data.system_layers });
- }
- },
- 0,
- (err) => {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- // Fallback to HTTP AssistentChat
- console.warn('AssistentChatWS failed, falling back', err);
- if (state.streamEl) {
- state.streamEl.remove();
- state.streamEl = null;
- state.streamMeta = null;
- }
- genericRequest(
- 'AssistentChat',
- payload,
- (data) => {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- if (data.error) {
- finishErr(String(data.error));
- return;
- }
- const reply = data.reply || '';
- appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
- finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
- },
- 0,
- (err2) => finishErr(String(err2 || err || 'Chat failed')),
- );
- },
- );
- return;
- }
-
- genericRequest(
- 'AssistentChat',
- payload,
- (data) => {
- if (chatEpoch !== state.chatEpoch) {
- return;
- }
- if (data.error) {
- finishErr(String(data.error));
- return;
- }
- const reply = data.reply || '';
- appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
- finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
- },
- 0,
- (err) => finishErr(String(err || 'Chat failed')),
- );
- }
-
- function wireDropZone() {
- const board = $('sa_board');
- const layout = $('sa_layout');
- layout?.addEventListener('dragover', (e) => {
- if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/uri-list')) {
- e.preventDefault();
- }
- });
- layout?.addEventListener('drop', async (e) => {
- if (!e.dataTransfer) {
- return;
- }
- if (e.target && e.target.closest && e.target.closest('.sa-slot, .sa-add-cell')) {
- return;
- }
- e.preventDefault();
- await handleDropDataTransfer(e.dataTransfer);
- });
- board?.addEventListener('keydown', (e) => {
- if (e.key === 'Delete' || e.key === 'Backspace') {
- if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
- return;
- }
- e.preventDefault();
- clearSlot(state.selectedSlotId);
- }
- });
-
- document.addEventListener('paste', async (e) => {
- const pane = document.getElementById('assistent');
- if (!pane || !pane.classList.contains('active')) {
- return;
- }
- if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
- const items = e.clipboardData?.items;
- let hasImage = false;
- if (items) {
- for (const item of items) {
- if (item.type.startsWith('image/')) {
- hasImage = true;
- break;
- }
- }
- }
- if (!hasImage) {
- return;
- }
- }
- const items = e.clipboardData?.items;
- if (!items) {
- return;
- }
- for (const item of items) {
- if (item.type.startsWith('image/')) {
- e.preventDefault();
- const file = item.getAsFile();
- if (file) {
- const sel = selectedSlot();
- await acceptImageFile(file, sel && sel.type === 'ref' ? sel.id : null);
- }
- return;
- }
- }
- });
- }
-
- function wireSplitter() {
- const splitter = $('sa_splitter');
- const layout = $('sa_layout');
- const pane = $('sa_image_pane');
- if (!splitter || !layout || !pane) {
- return;
- }
- let dragging = false;
- splitter.addEventListener('mousedown', (e) => {
- e.preventDefault();
- dragging = true;
- splitter.classList.add('sa-dragging');
- document.body.style.cursor = 'col-resize';
- document.body.style.userSelect = 'none';
- });
- window.addEventListener('mousemove', (e) => {
- if (!dragging) {
- return;
- }
- const rect = layout.getBoundingClientRect();
- const x = e.clientX - rect.left;
- const pct = Math.min(56, Math.max(22, (x / rect.width) * 100));
- const value = `${pct}%`;
- document.documentElement.style.setProperty('--sa-image-width', value);
- localStorage.setItem(LS_PANE_WIDTH, value);
- });
- window.addEventListener('mouseup', () => {
- if (!dragging) {
- return;
- }
- dragging = false;
- splitter.classList.remove('sa-dragging');
- document.body.style.cursor = '';
- document.body.style.userSelect = '';
- saveUiStateToDisk();
- });
- }
-
- function registerSendButton() {
- if (typeof registerMediaButton !== 'function') {
- setTimeout(registerSendButton, 500);
- return;
- }
- if (window.__swarmAssistentMediaRegistered) {
- return;
- }
- window.__swarmAssistentMediaRegistered = true;
- registerMediaButton(
- 'Send to Assistent',
- (src) => {
- putImageOnBoard(src, {
- switchTab: true,
- note: 'Image sent to Assistent',
- preferSelected: false,
- });
- const pack = $('sa_pack');
- if (pack && (pack.value === 'ordinary' || pack.value === 'write_prompt')) {
- pack.value = 'critique_image';
- saveSettings();
- }
- },
- 'Open Assistent with this image (vision / critique / prompt help)',
- ['image'],
- true,
- true,
- );
- }
-
- /** Disk state first (chats + UI prefs), then config / models / inventory. */
- async function bootstrapPersisted() {
- try {
- await applyDiskUiState();
- } catch (e) {
- console.warn('Assistent: ui-state restore failed', e);
- }
- try {
- await initChatSessions();
- } catch (e) {
- console.warn('Assistent: chat sessions failed', e);
- }
- loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
- refreshModels();
- refreshInventory(() => {
- renderCardsList();
- renderLoraChips();
- });
- });
- probeOllamaHealth();
- refreshWantedQueue();
- }
-
- function wire() {
- if (!$('swarm_assistent_root')) {
- return;
- }
- if (typeof genericRequest !== 'function') {
- setTimeout(wire, 300);
- return;
- }
- if (window.__swarmAssistentWired) {
- return;
- }
- window.__swarmAssistentWired = true;
- loadSettings();
- loadTaste();
- loadTasteFromServer();
- setView(state.view || 'chat');
- updateGate();
- ensureBoard();
- setBoardTab(state.boardTab || 'generate', { persist: false });
- syncGenerateSlot();
- if (wantsAutoVision()) {
- refreshImagePreview();
- }
- bootstrapPersisted();
- wireDropZone();
- wireSplitter();
- registerSendButton();
- wireSlashInput();
- wireCardForm();
-
- $('sa_btn_new_chat')?.addEventListener('click', () => startNewChat({ saveCurrent: true }));
- $('sa_btn_chats')?.addEventListener('click', (e) => {
- e.stopPropagation();
- setChatsPanelOpen(!state.chatsPanelOpen);
- });
- $('sa_session_label')?.addEventListener('click', (e) => {
- e.stopPropagation();
- setChatsPanelOpen(!state.chatsPanelOpen);
- });
- $('sa_session_label')?.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- setChatsPanelOpen(!state.chatsPanelOpen);
- }
- });
- $('sa_chats_panel')?.addEventListener('click', (e) => e.stopPropagation());
- $('sa_chats_list')?.addEventListener('click', (e) => {
- const row = e.target.closest('.sa-chat-row');
- if (!row) {
- return;
- }
- const id = row.dataset.id;
- if (e.target.closest('[data-del]')) {
- e.preventDefault();
- if (window.confirm('Удалить этот чат из истории?')) {
- deleteChat(id);
- }
- return;
- }
- if (e.target.closest('[data-open]')) {
- switchToChat(id);
- }
- });
- let chatsSearchTimer = null;
- $('sa_chats_search')?.addEventListener('input', () => {
- const q = ($('sa_chats_search')?.value || '').trim();
- state.chatsQuery = q;
- if (!q) {
- state.chatsSearchHits = null;
- renderChatsList();
- return;
- }
- renderChatsList();
- clearTimeout(chatsSearchTimer);
- chatsSearchTimer = setTimeout(async () => {
- try {
- const hits = await diskPersist()?.searchChats?.(q);
- if ((state.chatsQuery || '') !== q) {
- return;
- }
- state.chatsSearchHits = Array.isArray(hits) ? hits : [];
- renderChatsList();
- } catch (e) { /* ignore */ }
- }, 220);
- });
-
- $('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
- $('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
- $('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
- $('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
- $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
- $('sa_persona')?.addEventListener('change', onPersonaChanged);
- $('sa_persona_delete')?.addEventListener('click', () => deleteCurrentOverlayPersona());
- $('sa_cards_kind')?.addEventListener('change', renderCardsList);
- $('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true }));
- $('sa_btn_card_meta')?.addEventListener('click', () => fetchCardMetaLive());
- $('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
- $('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
- $('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
- $('sa_btn_settings')?.addEventListener('click', () => {
- if (state.view === 'settings') {
- closeSettings();
- } else {
- openSettings(state.settingsTab || 'behavior');
- }
- });
- $('sa_settings_close')?.addEventListener('click', () => closeSettings());
- document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
- btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
- });
- $('sa_btn_mem_refresh')?.addEventListener('click', () => {
- refreshMemoryList();
- refreshWantedQueue();
- });
- $('sa_mem_kind')?.addEventListener('change', renderMemoryList);
- $('sa_mem_scope')?.addEventListener('change', renderMemoryList);
- $('sa_mem_search')?.addEventListener('input', () => renderMemoryList());
- $('sa_btn_mem_clear_kind')?.addEventListener('click', () => {
- const kind = memoryKindFilter();
- clearCraftMemory({ kind: kind === 'all' ? '' : kind, label: kind === 'all' ? 'весь крафт (фильтр типа)' : `тип ${kind}` });
- });
- $('sa_btn_mem_clear_persona')?.addEventListener('click', () => {
- clearCraftMemory({ scope: 'personal', persona: $('sa_persona')?.value || 'neutral', label: 'крафт этой личности' });
- });
- $('sa_btn_mem_clear_shared')?.addEventListener('click', () => {
- clearCraftMemory({ scope: 'shared', label: 'общую крафт-память' });
- });
- $('sa_btn_mem_clear_all')?.addEventListener('click', () => {
- clearCraftMemory({ label: 'весь крафт (non-bundled)' });
- });
- $('sa_btn_prefs_refresh')?.addEventListener('click', () => refreshUserPrefs());
- $('sa_btn_pref_add_global')?.addEventListener('click', () => addUserPref('global'));
- $('sa_btn_pref_add_persona')?.addEventListener('click', () => addUserPref('persona'));
- $('sa_btn_prefs_clear_global')?.addEventListener('click', () => clearUserPrefs('global'));
- $('sa_btn_prefs_clear_persona')?.addEventListener('click', () => clearUserPrefs('persona'));
- $('sa_btn_prefs_clear_all')?.addEventListener('click', () => clearUserPrefs('all'));
- $('sa_user_prefs_weight')?.addEventListener('input', () => {
- const lab = $('sa_user_prefs_weight_val');
- if (lab) {
- lab.textContent = Number($('sa_user_prefs_weight').value).toFixed(1);
- }
- });
- $('sa_user_prefs_weight')?.addEventListener('change', () => saveKnobs());
- $('sa_memory_top_k')?.addEventListener('change', () => saveKnobs());
- $('sa_btn_knobs_save')?.addEventListener('click', () => saveKnobs());
- $('sa_btn_reset_ui')?.addEventListener('click', () => resetUiState());
- $('sa_btn_persona_export')?.addEventListener('click', () => exportSelectedPersona());
- $('sa_btn_persona_import')?.addEventListener('click', () => $('sa_persona_import_file')?.click());
- $('sa_persona_import_file')?.addEventListener('change', (e) => {
- const file = e.target?.files?.[0];
- if (file) {
- importPersonaFile(file);
- }
- e.target.value = '';
- });
- $('sa_btn_persona_clone')?.addEventListener('click', () => cloneSelectedPersona());
- $('sa_btn_persona_delete_panel')?.addEventListener('click', () => deleteSelectedOverlayPersona());
- $('sa_btn_settings_health')?.addEventListener('click', () => {
- probeOllamaHealth();
- setTimeout(syncSettingsHealthLine, 400);
- });
- $('sa_settings_chat_model')?.addEventListener('change', () => {
- const v = $('sa_settings_chat_model')?.value;
- if (v && $('sa_model')) {
- $('sa_model').value = v;
- saveSettings();
- }
- });
- $('sa_btn_look_result')?.addEventListener('click', () => askLookAtResult());
- $('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
- document.addEventListener('keydown', (e) => {
- if (e.key !== 'Escape') {
- return;
- }
- let closed = false;
- if (state.view === 'settings') {
- closeSettings();
- closed = true;
- }
- if (state.chatsPanelOpen) {
- setChatsPanelOpen(false);
- closed = true;
- }
- const slash = $('sa_slash_menu');
- if (slash && !slash.hidden) {
- slash.hidden = true;
- closed = true;
- }
- closeAllMoreMenus();
- if (closed) {
- e.preventDefault();
- }
- });
- // Focus composer when Assistent tab becomes visible
- document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => {
- setTimeout(() => $('sa_input')?.focus(), 80);
- });
- $('sa_btn_refresh_models')?.addEventListener('click', () => {
- saveSettings();
- refreshModels();
- probeOllamaHealth();
- });
- $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
- renderCardsList();
- renderLoraChips();
- }, { rescan: true }));
- $('sa_btn_add_ref')?.addEventListener('click', () => {
- setBoardTab('refs');
- addRefSlot({ select: true });
- });
- $('sa_btn_use_current')?.addEventListener('click', () => snapshotGenerateToRef());
- $('sa_btn_as_init')?.addEventListener('click', async () => {
- closeAllMoreMenus();
- const src = selectedSrc() || findCurrentGenerateSrc();
- if (!src) {
- setStatus('Нет изображения для Init');
- return;
- }
- await setInitFromSrc(src);
- const pack = $('sa_pack');
- if (pack && (pack.value === 'ordinary' || pack.value === 'write_prompt')) {
- setPackValue('inpaint_edit', { flash: true });
- }
- });
- $('sa_btn_as_mask')?.addEventListener('click', async () => {
- closeAllMoreMenus();
- const src = selectedSrc();
- if (!src) {
- setStatus('Выбери окно с маской');
- return;
- }
- await setMaskFromSrc(src);
- setPackValue('inpaint_edit', { flash: true });
- });
- $('sa_btn_clear_init')?.addEventListener('click', () => {
- clearInitAndMask();
- closeAllMoreMenus();
- });
- $('sa_btn_clear_image')?.addEventListener('click', () => clearSlot(state.selectedSlotId));
- $('sa_btn_board_more')?.addEventListener('click', (e) => {
- e.stopPropagation();
- toggleMoreMenu('sa_board_more_menu', 'sa_btn_board_more');
- });
- $('sa_btn_send')?.addEventListener('click', () => sendChat());
- $('sa_btn_build_gen')?.addEventListener('click', () => buildCurrentAndGenerate());
- $('sa_btn_interrupt')?.addEventListener('click', () => {
- doInterruptNow();
- clearInFlightUi({ status: 'Прервано' });
- });
- $('sa_btn_clear')?.addEventListener('click', () => {
- if (window.confirm('Очистить весь чат Assistent?')) {
- clearChatHistory();
- }
- });
- $('sa_btn_clear_more')?.addEventListener('click', (e) => {
- e.stopPropagation();
- toggleMoreMenu('sa_clear_more_menu', 'sa_btn_clear_more');
- });
- $('sa_btn_clear_confirm')?.addEventListener('click', () => {
- closeAllMoreMenus();
- if (window.confirm('Очистить весь чат Assistent?')) {
- clearChatHistory();
- }
- });
- $('sa_btn_clear_patches')?.addEventListener('click', () => {
- closeAllMoreMenus();
- clearPatchBlocksOnly();
- });
- $('sa_btn_card_to_chat')?.addEventListener('click', () => {
- if (state.cardsSelection) {
- sendCardToChat(state.cardsSelection);
- } else {
- setCardStatus('Сначала выбери модель в списке');
- }
- });
- document.addEventListener('click', () => {
- if (state.chatsPanelOpen) {
- setChatsPanelOpen(false);
- }
- closeAllMoreMenus();
- });
- $('sa_board_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
- $('sa_clear_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
- $('sa_base_url')?.addEventListener('change', saveSettings);
- $('sa_model')?.addEventListener('change', () => {
- const v = $('sa_model')?.value;
- if (v && $('sa_settings_chat_model')) {
- $('sa_settings_chat_model').value = v;
- }
- saveSettings();
- });
- $('sa_embed_model')?.addEventListener('change', () => {
- state.preferredEmbed = $('sa_embed_model')?.value || '';
- saveSettings();
- });
- $('sa_pack')?.addEventListener('change', () => {
- state.packUserTouched = true;
- saveSettings();
- syncModeBadge();
- });
- $('sa_chips')?.addEventListener('click', async (e) => {
- const btn = e.target.closest('.sa-chip');
- if (!btn || state.busy || state.generating) {
- return;
- }
- const aspect = btn.getAttribute('data-aspect');
- const seed = btn.getAttribute('data-seed');
- const vary = btn.getAttribute('data-vary');
- const profile = btn.getAttribute('data-krea-profile');
- if (aspect) {
- await applyQuickPatch({ aspect, actions: ['generate'] }, `Aspect ${aspect}`);
- } else if (seed === 'lock') {
- await applyQuickPatch({ lock_seed: true }, 'Seed locked');
- } else if (seed === 'random') {
- await applyQuickPatch({ seed: -1, actions: ['generate'] }, 'Seed random');
- } else if (vary) {
- await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary');
- } else if (profile === 'turbo') {
- const p = state.kreaProfiles?.turbo || mergedGenerationDefaults('turbo');
- await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo');
- } else if (profile === 'raw') {
- const p = state.kreaProfiles?.raw || mergedGenerationDefaults('raw');
- await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, sigma_shift: p.sigma_shift, actions: ['generate'] }, 'RAW');
- }
- renderLoraChips();
- });
- $('sa_auto_vision')?.addEventListener('change', () => {
- saveSettings();
- const gen = generateSlot();
- if (gen) {
- gen.attach = wantsAutoVision();
- renderBoard();
- }
- });
- $('sa_auto_apply')?.addEventListener('change', saveSettings);
- $('sa_auto_generate')?.addEventListener('change', saveSettings);
- $('sa_auto_critique')?.addEventListener('change', saveSettings);
- $('sa_auto_download')?.addEventListener('change', saveSettings);
- $('sa_park_llm')?.addEventListener('change', saveSettings);
-
- syncChipHighlight();
- setInterval(syncChipHighlight, 2500);
- setInterval(renderLoraChips, 4000);
- syncLiveParamsBar();
- setInterval(syncLiveParamsBar, 1200);
- syncModeBadge();
- syncBuildGenButton();
-
- setInterval(updateGate, 2000);
- setInterval(syncGenerateSlot, 700);
- setInterval(() => {
- if (!state.busy && !state.generating) {
- probeOllamaHealth();
- }
- }, 45000);
- setInterval(() => {
- if (!state.busy && !state.generating) {
- refreshWantedQueue();
- }
- }, 120000);
- window.addEventListener('beforeunload', () => {
- try {
- saveActiveChatToStore({ dropEmpty: true });
- const chat = findChat(state.activeChatId);
- if (chat && (chat.messages || []).length) {
- diskPersist()?.saveChat(chat, { immediate: true });
- }
- diskPersist()?.saveUiState(collectUiState(), { immediate: true });
- } catch (e) { /* ignore */ }
- });
- setInterval(() => {
- if (!state.busy) {
- const tabOn = !!document.getElementById(TAB_BUTTON_ID)?.classList.contains('tab-button-selected')
- || !!document.getElementById('swarm_assistent_root')?.offsetParent;
- refreshInventory(null, { rescan: inventoryIsStale(tabOn ? 45000 : 120000) });
- }
- }, 30000);
-
- window.swarmAssistent = {
- setImageFromSrc,
- putImageOnBoard,
- clearVisionImage,
- snapshotGenerateToRef,
- openAssistentTab,
- sendToAssistent: (src) => {
- putImageOnBoard(src, { switchTab: true, note: 'Изображение отправлено в Assistent', preferSelected: false });
- setBoardTab('refs');
- },
- isKreaSelected,
- resolveCurrentCheckpoint,
- refreshInventory,
- applyPatch,
- triggerGenerate,
- setInitFromSrc,
- setMaskFromSrc,
- clearInitAndMask,
- slotById,
- renderBoard,
- setBoardTab,
- };
- }
-
- function wireSlashInput() {
- const input = $('sa_input');
- if (!input || input.dataset.saSlashWired) {
- return;
- }
- input.dataset.saSlashWired = '1';
- input.addEventListener('input', () => updateSlashMenuFromInput());
- input.addEventListener('keydown', (e) => {
- const menu = $('sa_slash_menu');
- const open = menu && !menu.hidden;
- if (open) {
- const items = slashMatches((input.value.split(/\s/)[0] || ''));
- if (e.key === 'ArrowDown') {
- e.preventDefault();
- state.slashIndex = Math.min(items.length - 1, (state.slashIndex || 0) + 1);
- renderSlashMenu(items);
- return;
- }
- if (e.key === 'ArrowUp') {
- e.preventDefault();
- state.slashIndex = Math.max(0, (state.slashIndex || 0) - 1);
- renderSlashMenu(items);
- return;
- }
- if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
- const pick = items[state.slashIndex || 0];
- if (pick && input.value.trim() === (input.value.split(/\s/)[0] || '')) {
- e.preventDefault();
- applySlashPick(pick);
- return;
- }
- }
- if (e.key === 'Escape') {
- hideSlashMenu();
- return;
- }
- }
- if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
- e.preventDefault();
- hideSlashMenu();
- sendChat();
- }
- });
- input.addEventListener('blur', () => setTimeout(hideSlashMenu, 150));
- }
-
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', wire);
- } else {
- wire();
- }
-})();
+/**
+ * Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
+ * v0.8.0: Split assets — SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
+ */
+(function () {
+ const LS_BASE = 'swarm_assistent_base_url';
+ const LS_MODEL = 'swarm_assistent_model';
+ const LS_EMBED = 'swarm_assistent_embed_model';
+ const LS_PACK = 'swarm_assistent_pack';
+ const LS_PACK_ORDINARY_MIG = 'swarm_assistent_pack_ordinary_v1';
+ /** One-shot: drop junior chat tags stuck in LS so UI/warm pick preferred/senior. */
+ const LS_MODEL_SENIOR_MIG = 'swarm_assistent_model_senior_v1';
+ const LS_PERSONA = 'swarm_assistent_persona';
+ const LS_VIEW = 'swarm_assistent_view';
+ const LS_AUTO_VISION = 'swarm_assistent_auto_vision';
+ const LS_AUTO_APPLY = 'swarm_assistent_auto_apply';
+ const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate';
+ const LS_AUTO_CRITIQUE = 'swarm_assistent_auto_critique';
+ const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download';
+ /** When '1', unload chat LLM before Generate (frees VRAM; VL reload can take 1–2 min). Default off. */
+ const LS_PARK_LLM = 'swarm_assistent_park_llm';
+ const LS_PANE_WIDTH = 'swarm_assistent_pane_width';
+ const LS_WELCOMED = 'swarm_assistent_welcomed';
+ const LS_TASTE = 'swarm_assistent_taste';
+ const LS_HISTORY = 'swarm_assistent_history';
+ const LS_CHATS = 'swarm_assistent_chats_v1';
+ const LS_BOARD_TAB = 'swarm_assistent_board_tab';
+ const MAX_CHATS = 40;
+ const MAX_CHAT_MSGS = 24;
+ const TAB_BUTTON_ID = 'maintab_assistent';
+ const GEN_ID = 'generate';
+ let MAX_REF_SLOTS = 4;
+ let MAX_GEN_VARIANTS = 4;
+ let CONTEXT_PROMPT_MAX = 2000;
+ let HISTORY_KEEP_TURNS = 4;
+ let INVENTORY_PROMPT_RICH = 12;
+ let INVENTORY_PROMPT_NAMES = 24;
+
+ let ASPECT_TABLE = {
+ '1:1': [1024, 1024],
+ '4:3': [1184, 896],
+ '3:2': [1248, 832],
+ '16:9': [1376, 768],
+ '2.35:1': [1568, 672],
+ '4:5': [928, 1152],
+ '2:3': [832, 1248],
+ '9:16': [768, 1376],
+ };
+
+ let PACK_ALIASES = {
+ ordinary: 'ordinary',
+ combine: 'ordinary',
+ normal: 'ordinary',
+ general: 'ordinary',
+ default: 'ordinary',
+ write: 'write_prompt',
+ write_prompt: 'write_prompt',
+ critique: 'critique_image',
+ critique_image: 'critique_image',
+ compose: 'compose_scene',
+ compose_scene: 'compose_scene',
+ params: 'fix_params',
+ fix_params: 'fix_params',
+ inpaint: 'inpaint_edit',
+ inpaint_edit: 'inpaint_edit',
+ describe: 'describe_ref',
+ describe_ref: 'describe_ref',
+ card: 'catalog_card',
+ catalog: 'catalog_card',
+ catalog_card: 'catalog_card',
+ };
+
+ let WELCOME_HTML = `
+ Assistent · Krea 2
+
+ Generate слева — живой просмотр. В чат сам не уходит.
+ Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.
+ Галочка vision на окне — отправить кадр модели.
+ Чипсы aspect / seed / Vary / Turbo·RAW. В чате: /help.
+ Кнопки патча только у последнего предложения.
+
+ Напиши, что сгенерировать — или кинь референс и попроси правку.`;
+
+ 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 — поиск LoRA (Confirm в чате)
+/inventory — rescan моделей + обновить список LoRA
+
+Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.
+При старте всегда новый чат; смена чата в Истории восстанавливает параметры.`;
+
+ let SLASH_COMMANDS = [
+ { cmd: '/help', hint: 'список команд' },
+ { cmd: '/new', hint: 'новый чат' },
+ { cmd: '/history', hint: 'история чатов' },
+ { cmd: '/debug', hint: 'сводка · ask = с LLM' },
+ { cmd: '/why', hint: 'debug + пояснение LLM' },
+ { cmd: '/gen', hint: 'Generate сейчас' },
+ { cmd: '/look ', hint: 'generate|refN' },
+ { cmd: '/init', hint: 'как Init' },
+ { cmd: '/mask', hint: 'как Mask' },
+ { cmd: '/clear', hint: 'сброс Init/Mask' },
+ { cmd: '/interrupt', hint: 'стоп' },
+ { cmd: '/aspect ', hint: '16:9' },
+ { cmd: '/seed ', hint: 'lock|random' },
+ { cmd: '/vary', hint: 'новый seed' },
+ { cmd: '/pack ', hint: 'write|critique|…' },
+ { cmd: '/civitai ', hint: 'запрос LoRA' },
+ { cmd: '/inventory', hint: 'rescan моделей' },
+ ];
+
+ const state = {
+ history: [],
+ packsLoaded: false,
+ config: null,
+ exact: null,
+ sessionExact: {},
+ lastUserParamIntent: false,
+ lastUserControlIntent: false,
+ lastPatch: null,
+ pendingSilentGen: false,
+ pendingPromptEnMerge: null,
+ enabledSkills: [],
+ kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } },
+ preferredEmbed: null,
+ busy: false,
+ generating: false,
+ chatEpoch: 0,
+ waitImageTimer: null,
+ lastImageDataUrl: null,
+ preferredModel: null,
+ dragDepth: 0,
+ inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
+ inventoryFetchedAt: 0,
+ taste: { styles: [], likes: [], avoid: [], notes: '', updated: 0 },
+ tasteSaveTimer: null,
+ streamEl: null,
+ streamMeta: null,
+ streamText: '',
+ streamFenceDone: false,
+ critiqueHopUsed: false,
+ visionHopUsed: false,
+ lastSystemChars: 0,
+ lastSystemLayers: null,
+ lastContextChars: 0,
+ busyPhase: 'idle',
+ busyStarted: 0,
+ gotDelta: false,
+ busyTimer: null,
+ slots: [],
+ selectedSlotId: 'ref1',
+ refSeq: 1,
+ genResults: [],
+ selectedGenResultId: null,
+ lightboxIndex: -1,
+ packUserTouched: false,
+ view: 'chat',
+ boardTab: 'generate',
+ personas: [],
+ modelCards: {},
+ cardsSelection: null,
+ cardsBusy: false,
+ pendingPersonaNote: null,
+ chats: [],
+ activeChatId: null,
+ restoringChat: false,
+ chatsPanelOpen: false,
+ chatsQuery: '',
+ chatsSearchHits: null,
+ slashIndex: 0,
+ llmParked: false,
+ expectColdLoad: false,
+ memoryRows: [],
+ userPrefs: [],
+ settingsTab: 'behavior',
+ settingsPersonaId: null,
+ wanted: { count: 0, items: [] },
+ wantedKeys: new Set(),
+ ollamaHealth: 'unknown',
+ };
+
+ /** Disk persistence module (assistent.persist.js) — absent means localStorage only. */
+ function diskPersist() {
+ return (window.SA && window.SA.persist) || null;
+ }
+
+ function $(id) {
+ return document.getElementById(id);
+ }
+
+ function modelShort(name) {
+ const s = String(name || '');
+ const slash = s.lastIndexOf('/');
+ return (slash >= 0 ? s.slice(slash + 1) : s) || 'model';
+ }
+
+ function fmtElapsed(ms) {
+ const s = Math.max(0, Math.floor(ms / 1000));
+ if (s < 60) {
+ return `${s}s`;
+ }
+ return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`;
+ }
+
+ function hideChatEmpty() {
+ const empty = $('sa_chat_empty');
+ if (empty) {
+ empty.hidden = true;
+ }
+ }
+
+ let scrollMessagesRaf = 0;
+
+ /** Autoscroll only when already near the bottom — otherwise streaming fights the user's scroll (shakes). */
+ function messagesNearBottom(thresholdPx = 96) {
+ const box = $('sa_messages');
+ if (!box) {
+ return true;
+ }
+ return (box.scrollHeight - box.scrollTop - box.clientHeight) <= thresholdPx;
+ }
+
+ function scrollMessagesToBottom({ force = false } = {}) {
+ const box = $('sa_messages');
+ if (!box) {
+ return;
+ }
+ if (!force && !messagesNearBottom()) {
+ return;
+ }
+ if (scrollMessagesRaf) {
+ return;
+ }
+ scrollMessagesRaf = requestAnimationFrame(() => {
+ scrollMessagesRaf = 0;
+ const el = $('sa_messages');
+ if (el && (force || messagesNearBottom(120))) {
+ el.scrollTop = el.scrollHeight;
+ }
+ });
+ }
+
+ function showChatEmptyIfIdle() {
+ const box = $('sa_messages');
+ const empty = $('sa_chat_empty');
+ if (!box || !empty) {
+ return;
+ }
+ const hasMsg = [...box.children].some((el) => el.id !== 'sa_chat_empty');
+ empty.hidden = hasMsg;
+ }
+
+ function setBusyPhase(phase) {
+ state.busyPhase = phase || 'thinking';
+ tickBusyUi();
+ syncPatchActionAvailability();
+ syncGenerateBusy();
+ }
+
+ function tickBusyUi() {
+ if (state.busyPhase === 'idle') {
+ return;
+ }
+ const elapsed = Date.now() - (state.busyStarted || Date.now());
+ // Only claim "Loading into GPU" when the model was parked / known cold.
+ // Otherwise a slow first token on an already-resident VL looks like a 2‑min reload.
+ if (!state.gotDelta && (state.busyPhase === 'thinking' || state.busyPhase === 'waiting') && elapsed > 1600) {
+ state.busyPhase = state.llmParked || state.expectColdLoad ? 'loading' : 'waiting';
+ }
+ const model = modelShort($('sa_model')?.value);
+ const labels = {
+ encoding: 'Encoding image…',
+ waiting: 'Жду Ollama / первый токен…',
+ loading: `Загружаю ${model} в GPU… обычно 30–120 с после park`,
+ warming: `Возвращаю ${model} в GPU…`,
+ thinking: 'Thinking…',
+ streaming: 'Writing…',
+ generating: 'Generating image…',
+ parking: 'Освобождаю VRAM (park LLM)…',
+ applying: 'Applying patch…',
+ silent_gen: 'Применяю патч → Generate…',
+ refining: 'Civitai search done — refining…',
+ };
+ const text = labels[state.busyPhase] || 'Working…';
+ const barText = $('sa_livebar_text');
+ if (barText) {
+ barText.textContent = text;
+ }
+ const elapsedEl = $('sa_elapsed');
+ if (elapsedEl) {
+ elapsedEl.textContent = fmtElapsed(elapsed);
+ }
+ const status = $('sa_status');
+ if (status) {
+ status.textContent = text;
+ status.classList.add('sa-status-busy');
+ }
+ }
+
+ function startBusyUi(phase) {
+ state.busyStarted = Date.now();
+ state.gotDelta = false;
+ state.busyPhase = phase || 'thinking';
+ $('swarm_assistent_root')?.classList.add('sa-is-busy');
+ $('sa_composer')?.classList.add('sa-composer-busy');
+ const send = $('sa_btn_send');
+ if (send) {
+ send.disabled = true;
+ }
+ const input = $('sa_input');
+ if (input) {
+ input.classList.add('sa-input-busy');
+ }
+ const bar = $('sa_livebar');
+ if (bar) {
+ bar.hidden = false;
+ }
+ const dot = $('sa_live_dot');
+ if (dot) {
+ dot.hidden = false;
+ }
+ tickBusyUi();
+ syncPatchActionAvailability();
+ syncGenerateBusy();
+ if (state.busyTimer) {
+ clearInterval(state.busyTimer);
+ }
+ state.busyTimer = setInterval(tickBusyUi, 400);
+ }
+
+ function stopBusyUi(finalStatus) {
+ if (state.busyTimer) {
+ clearInterval(state.busyTimer);
+ state.busyTimer = null;
+ }
+ const elapsed = Date.now() - (state.busyStarted || Date.now());
+ state.busyPhase = 'idle';
+ $('swarm_assistent_root')?.classList.remove('sa-is-busy');
+ $('sa_composer')?.classList.remove('sa-composer-busy');
+ const send = $('sa_btn_send');
+ if (send) {
+ send.disabled = false;
+ }
+ const input = $('sa_input');
+ if (input) {
+ input.classList.remove('sa-input-busy');
+ }
+ const bar = $('sa_livebar');
+ if (bar) {
+ bar.hidden = true;
+ }
+ const dot = $('sa_live_dot');
+ if (dot) {
+ dot.hidden = true;
+ }
+ const status = $('sa_status');
+ if (status) {
+ status.classList.remove('sa-status-busy');
+ }
+ if (finalStatus != null) {
+ const suffix = elapsed >= 1000 ? ` · ${fmtElapsed(elapsed)}` : '';
+ setStatus(finalStatus + suffix);
+ }
+ syncPatchActionAvailability();
+ syncGenerateBusy();
+ }
+
+ function setStatus(text) {
+ const el = $('sa_status');
+ if (el) {
+ el.textContent = text || '';
+ }
+ }
+
+ function setInterruptVisible(on) {
+ const btn = $('sa_btn_interrupt');
+ if (btn) {
+ btn.hidden = !on;
+ btn.classList.toggle('sa-interrupt-active', !!on);
+ }
+ }
+
+ function looksLikeKrea(text) {
+ const s = String(text || '');
+ return /krea\s*2|krea2|krea-2/i.test(s) || /krea/i.test(s);
+ }
+
+ function resolveCurrentCheckpoint() {
+ const out = {
+ name: null,
+ architecture: null,
+ compat_class: null,
+ title: null,
+ class: null,
+ source: null,
+ };
+ try {
+ if (typeof currentModelHelper !== 'undefined' && currentModelHelper) {
+ out.name = currentModelHelper.curModel || null;
+ out.architecture = currentModelHelper.curArch || null;
+ out.compat_class = currentModelHelper.curCompatClass || null;
+ out.source = 'currentModelHelper';
+ }
+ } catch (e) { /* ignore */ }
+
+ try {
+ if (typeof getCurrentModel === 'function') {
+ const model = getCurrentModel();
+ if (model) {
+ out.name = out.name || model.name || null;
+ out.title = model.title || null;
+ out.architecture = out.architecture || model.architecture || null;
+ out.class = model.class || null;
+ out.compat_class = out.compat_class || model.compat_class || null;
+ out.source = out.source || 'getCurrentModel';
+ }
+ }
+ } catch (e) { /* ignore */ }
+
+ try {
+ const sel =
+ document.getElementById('current_model') ||
+ document.getElementById('input_model');
+ if (sel) {
+ const opt = sel.selectedOptions && sel.selectedOptions[0];
+ const hint = [
+ sel.value,
+ opt && opt.text,
+ opt && opt.dataset && opt.dataset.cleanname,
+ ]
+ .filter(Boolean)
+ .join(' ');
+ if (!out.name && sel.value) {
+ out.name = sel.value;
+ out.source = out.source || 'dropdown';
+ }
+ if (hint && !out.architecture) {
+ out.title = out.title || hint;
+ }
+ }
+ } catch (e) { /* ignore */ }
+
+ return out;
+ }
+
+ function isKreaSelected() {
+ try {
+ const m = resolveCurrentCheckpoint();
+ const blob = [
+ m.architecture,
+ m.compat_class,
+ m.title,
+ m.name,
+ m.class,
+ ].join(' ');
+ return looksLikeKrea(blob);
+ } catch (e) {
+ return false;
+ }
+ }
+
+ function updateGate() {
+ const ok = isKreaSelected();
+ const gate = $('sa_gate');
+ const layout = $('sa_layout');
+ if (gate) {
+ gate.hidden = ok;
+ if (!ok) {
+ const m = resolveCurrentCheckpoint();
+ const seen = [m.architecture, m.compat_class, m.name]
+ .filter(Boolean)
+ .join(' · ');
+ const p = gate.querySelector('p');
+ if (p) {
+ p.innerHTML = seen
+ ? `Swarm Assistent is for Krea 2 models only. Current: ${escapeHtml(
+ seen,
+ )} — pick a checkpoint with architecture krea-2.`
+ : 'Swarm Assistent is for Krea 2 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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ /** Known ### headings → RU display labels. JSON Patch is hidden (real UI is the patch strip). */
+ const PROSE_SECTION_TITLES = {
+ critique: 'Критика',
+ критика: 'Критика',
+ analysis: 'Разбор',
+ разбор: 'Разбор',
+ notes: 'Заметки',
+ заметки: 'Заметки',
+ summary: 'Кратко',
+ кратко: 'Кратко',
+ prompt: 'Промпт',
+ промпт: 'Промпт',
+ 'improved prompt': 'Промпт',
+ 'next prompt': 'Промпт',
+ deliverable: 'Итог',
+ итог: 'Итог',
+ verdict: 'Вердикт',
+ вердикт: 'Вердикт',
+ issues: 'Проблемы',
+ проблемы: 'Проблемы',
+ fixes: 'Правки',
+ правки: 'Правки',
+ suggestion: 'Предложение',
+ suggestions: 'Предложения',
+ предложения: 'Предложения',
+ };
+
+ function localizeProseHeading(raw) {
+ const cleaned = String(raw || '').replace(/[*_`#]/g, '').trim();
+ if (!cleaned) {
+ return null;
+ }
+ const key = cleaned.toLowerCase().replace(/\s+/g, ' ');
+ if (/^json\s*patch$/.test(key) || /^патч$/.test(key) || /^json\s*патч$/.test(key)) {
+ return null;
+ }
+ if (PROSE_SECTION_TITLES[key]) {
+ return PROSE_SECTION_TITLES[key];
+ }
+ // "Critique — blur" → take first token bucket
+ const head = key.split(/[—:\-|]/)[0].trim();
+ if (PROSE_SECTION_TITLES[head]) {
+ return PROSE_SECTION_TITLES[head];
+ }
+ return cleaned;
+ }
+
+ function formatProseInline(escapedLine) {
+ let t = escapedLine;
+ t = t.replace(/`([^`]+)`/g, '$1');
+ t = t.replace(/\*\*([^*]+)\*\*/g, '$1 ');
+ t = t.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2 ');
+ return t;
+ }
+
+ /** Lightweight chat prose: ### Critique → «Критика», lists, bold — not a full markdown engine. */
+ function formatAssistantProseHtml(raw) {
+ let text = String(raw || '').replace(/\r\n/g, '\n');
+ text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n');
+ text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n');
+ text = text.replace(/\n{3,}/g, '\n\n').trim();
+ if (!text) {
+ return '';
+ }
+ const lines = text.split('\n');
+ const parts = [];
+ let listItems = [];
+ const flushList = () => {
+ if (!listItems.length) {
+ return;
+ }
+ parts.push(
+ `${listItems.map((li) => `${formatProseInline(escapeHtml(li))} `).join('')} `,
+ );
+ listItems = [];
+ };
+ for (const line of lines) {
+ const heading = line.match(/^#{1,3}\s+(.+?)\s*$/);
+ if (heading) {
+ flushList();
+ const title = localizeProseHeading(heading[1]);
+ if (!title) {
+ continue;
+ }
+ const level = Math.min((line.match(/^#+/) || ['###'])[0].length, 3);
+ parts.push(
+ `${escapeHtml(title)}
`,
+ );
+ continue;
+ }
+ const bullet = line.match(/^\s*[-*•]\s+(.+)$/);
+ if (bullet) {
+ listItems.push(bullet[1]);
+ continue;
+ }
+ flushList();
+ if (!line.trim()) {
+ parts.push('
');
+ continue;
+ }
+ parts.push(`${formatProseInline(escapeHtml(line))}
`);
+ }
+ flushList();
+ return parts.join('');
+ }
+
+ function setAssistantBody(div, text, { live = false } = {}) {
+ if (!div) {
+ return;
+ }
+ let body = div.querySelector('.sa-msg-body');
+ if (!body) {
+ body = document.createElement('div');
+ body.className = 'sa-msg-body';
+ div.appendChild(body);
+ }
+ const raw = text || '';
+ // While streaming, plain text — full HTML reformat every token reflows and shakes scroll.
+ if (live) {
+ body.classList.add('sa-prose', 'sa-prose-live');
+ body.classList.remove('sa-prose-rich');
+ body.textContent = raw;
+ return;
+ }
+ body.classList.add('sa-prose', 'sa-prose-rich');
+ body.classList.remove('sa-prose-live');
+ const html = formatAssistantProseHtml(raw);
+ if (html) {
+ body.innerHTML = html;
+ } else {
+ body.textContent = '';
+ }
+ }
+
+ function val(id) {
+ const el = document.getElementById(id);
+ return el ? el.value : '';
+ }
+
+ function setVal(id, value) {
+ const el = document.getElementById(id);
+ if (!el) {
+ return;
+ }
+ el.value = value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ el.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+
+ function isEmptyParamField(raw, { treatZeroEmpty = false } = {}) {
+ if (raw == null) {
+ return true;
+ }
+ const s = String(raw).trim();
+ if (s === '') {
+ return true;
+ }
+ if (treatZeroEmpty && (s === '0' || Number(s) === 0)) {
+ return true;
+ }
+ return false;
+ }
+
+ /** JS \\b/\\w are ASCII-only — use this for RU tokens. `alts` = regex alternatives without outer parens. */
+ function cyrTokenRe(alts) {
+ const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
+ const end = '(?=$|[^0-9A-Za-z_А-Яа-яЁё])';
+ return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
+ }
+
+ /** Parse aspect from chat: 9:16, 9x16, 9×16, «9 на 16», «9 к 16». */
+ function parseAspectFromUserText(text) {
+ const t = String(text || '');
+ if (!t.trim()) {
+ return null;
+ }
+ const ratio = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*[:x×хX]\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/);
+ if (ratio) {
+ const key = normalizeAspect(`${ratio[1]}:${ratio[2]}`);
+ if (key) {
+ return key;
+ }
+ }
+ const na = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*(?:на|к|to)\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/i);
+ if (na) {
+ const key = normalizeAspect(`${na[1]}:${na[2]}`);
+ if (key) {
+ return key;
+ }
+ }
+ const named = t.match(/\b(16:9|9:16|1:1|4:5|2:3|3:2|4:3|2\.35:1)\b/i);
+ if (named) {
+ return normalizeAspect(named[1]);
+ }
+ if (cyrTokenRe('портрет|вертикал[а-яё]*').test(t) || /\b(portrait|vertical)\b/i.test(t)) {
+ return normalizeAspect('9:16') || normalizeAspect('2:3');
+ }
+ if (cyrTokenRe('альбом|горизонтал[а-яё]*').test(t) || /\b(landscape|horizontal|widescreen)\b/i.test(t)) {
+ return normalizeAspect('16:9');
+ }
+ return null;
+ }
+
+ /** «такую же, только 9 на 16» — keep prompt, change aspect, generate (no LLM needed). */
+ function isSameButAspectRequest(text) {
+ const t = String(text || '');
+ if (!parseAspectFromUserText(t)) {
+ return false;
+ }
+ return /такую\s+же|тот\s+же\s+промпт|same\s+(one|prompt|thing|again)|только\s+(поменя|смени|поставь)|поменяй\s+на|смени\s+на|only\s+change|just\s+change/i.test(t)
+ || /поменяй\s+(размер|aspect|соотношен)/i.test(t)
+ || /смени\s+(размер|aspect|соотношен)/i.test(t);
+ }
+
+ function userTextMentionsControls(text) {
+ const t = String(text || '');
+ if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) {
+ return true;
+ }
+ // Do not match bare «вкус» — too common in RU chat and was disabling the echo filter.
+ return cyrTokenRe('хорни|остынь|слайдер').test(t)
+ || /\/\s*(остынь|ostyn|horny-game)/i.test(t)
+ || /слайдер\s*вкус|вкус\s*(на|в)\s*\d|поставь\s*вкус|крутани\s*вкус/i.test(t);
+ }
+
+ function patchLooksLikeGeneration(patch) {
+ if (!patch || typeof patch !== 'object') {
+ return false;
+ }
+ if (patch.prompt != null || patch.loras || patch.aspect != null
+ || patch.width != null || patch.height != null || patch.steps != null
+ || patch.cfg != null || patch.seed != null) {
+ return true;
+ }
+ return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
+ }
+
+ /** Drop Generate-patch control noise; drop default-echo that would wipe a user-tuned slider. */
+ function filterControlPatch(incoming, patch) {
+ const schema = state.config?.controls || {};
+ const out = {};
+ if (!incoming || typeof incoming !== 'object') {
+ return out;
+ }
+ // Image patches must not move Хорни / Вкус unless the user asked this turn.
+ if (patchLooksLikeGeneration(patch) && !state.lastUserControlIntent) {
+ return out;
+ }
+ for (const [id, raw] of Object.entries(incoming)) {
+ if (!schema[id]) {
+ continue;
+ }
+ const n = Number(raw);
+ if (!Number.isFinite(n)) {
+ continue;
+ }
+ const def = Number(schema[id]?.default);
+ const cur = getControlValue(id, Number.isFinite(def) ? def : n);
+ if (Math.abs(n - cur) < 0.0005) {
+ continue;
+ }
+ if (!state.lastUserControlIntent
+ && Number.isFinite(def)
+ && Math.abs(n - def) < 0.0005
+ && Math.abs(cur - def) > 0.0005) {
+ continue;
+ }
+ out[id] = n;
+ }
+ return out;
+ }
+
+ function userTextMentionsParams(text) {
+ const t = String(text || '');
+ if (parseAspectFromUserText(t)) {
+ return true;
+ }
+ if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) {
+ return true;
+ }
+ return cyrTokenRe('размер|ширин[а-яё]*|высот[а-яё]*|соотношен[а-яё]*|турбо|портрет|вертикал[а-яё]*').test(t);
+ }
+
+ function replyMissingJsonPatch(reply) {
+ const t = String(reply || '');
+ if (!t.trim()) {
+ return false;
+ }
+ if (/```(?:json)?\s*\{[\s\S]*?\}```/i.test(t)) {
+ return false;
+ }
+ return /###\s*JSON\s*Patch\b/i.test(t) || /JSON\s*Patch\s*:?\s*$/im.test(t);
+ }
+
+ function userAsksGenerate(text) {
+ const t = String(text || '').trim();
+ if (!t) {
+ return false;
+ }
+ // Short imperatives only — do NOT treat bare «давай» as Generate (false positive on chat).
+ if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
+ return true;
+ }
+ if (/^(ещё|еще)(\s+раз)?([!.…\s]|$)/i.test(t)) {
+ return true;
+ }
+ // Do NOT use \b or \w — ASCII-only in JS; breaks «сделай картинку».
+ const letter = '[0-9A-Za-z_А-Яа-яЁё]';
+ const stem = `${letter}*`;
+ return cyrTokenRe(
+ 'сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|'
+ + `сделай\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ + `хочу\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ + 'run\\s+generat|/gen',
+ ).test(t);
+ }
+
+ /** «давай дальше / продолжай / следующий кадр» — continue the series with Generate. */
+ function userAsksContinue(text) {
+ const t = String(text || '').trim();
+ if (!t) {
+ return false;
+ }
+ if (/^(давай\s+дальше|продолжай|продолжим|go\s+on|continue|keep\s+going|next(\s+one)?|next\s+frame)([!.…\s]|$)/i.test(t)) {
+ return true;
+ }
+ return cyrTokenRe(
+ 'давай\\s+дальше|следующ(ий|ая|ее|ую)\\s+кадр|ещё\\s+кадр|еще\\s+кадр|'
+ + 'кадр\\s*№?\\s*\\d+|сделай\\s+следующ',
+ ).test(t);
+ }
+
+ /** Pull a usable prompt out of assistant prose when the JSON fence is missing. */
+ function extractPromptFromProse(reply) {
+ const t = String(reply || '').replace(/\r\n/g, '\n');
+ if (!t.trim()) {
+ return null;
+ }
+ const bq = [];
+ for (const line of t.split('\n')) {
+ const m = line.match(/^\s{0,3}>\s?(.*)$/);
+ if (m) {
+ bq.push(m[1]);
+ continue;
+ }
+ if (bq.length) {
+ break;
+ }
+ }
+ const fromBq = bq.join('\n').trim();
+ if (fromBq.length >= 48) {
+ return fromBq.slice(0, 4000);
+ }
+ const section = t.match(
+ /(?:^|\n)#{1,6}\s*(?:📷\s*)?(?:prompt|промпт|improved\s+prompt|next\s+prompt|кадр[^\n]*)\s*\n+([\s\S]+?)(?=\n#{1,6}\s|\n```|$)/i,
+ );
+ if (section) {
+ const body = section[1].replace(/^\s{0,3}>\s?/gm, '').trim();
+ if (body.length >= 48) {
+ return body.slice(0, 4000);
+ }
+ }
+ return null;
+ }
+
+ /** When the model wrote ### JSON Patch with no fence — build Apply+Generate from prose / last patch. */
+ function synthesizePatchAfterEmptyFence(reply, userText, opts = {}) {
+ const wants = !!(opts.userWantsGenerate || state.pendingSilentGen
+ || userAsksGenerate(userText) || userAsksContinue(userText));
+ const missing = replyMissingJsonPatch(reply);
+ if (!wants && !missing) {
+ return null;
+ }
+ const fromProse = extractPromptFromProse(reply);
+ const prompt = fromProse || state.lastPatch?.prompt || null;
+ if (!prompt) {
+ return null;
+ }
+ const patch = { prompt, actions: ['generate'] };
+ if (Array.isArray(state.lastPatch?.loras) && state.lastPatch.loras.length) {
+ patch.loras = state.lastPatch.loras;
+ }
+ if (state.lastPatch?.aspect) {
+ patch.aspect = state.lastPatch.aspect;
+ }
+ return patch;
+ }
+
+ /**
+ * Chat model prepares Generate prompts for Krea. Skip the prep hop only when the
+ * prompt already looks like solid English Krea prose for Qwen3-VL.
+ */
+ function promptLooksKreaReady(prompt) {
+ const t = String(prompt || '').trim();
+ if (t.length < 80) {
+ return false;
+ }
+ const cyr = (t.match(/[\u0400-\u04FF]/g) || []).length;
+ const lat = (t.match(/[A-Za-z]/g) || []).length;
+ if (cyr >= 12) {
+ return false;
+ }
+ if (lat < 55) {
+ return false;
+ }
+ // Prefer a real paragraph (clauses), not a one-liner dump.
+ if (t.length < 120 && (t.match(/[,.;:]/g) || []).length < 2) {
+ return false;
+ }
+ return true;
+ }
+
+ function promptNeedsKreaPrep(prompt) {
+ return !promptLooksKreaReady(prompt);
+ }
+
+ function buildKreaPromptPrepRequest(patch) {
+ const keep = {
+ actions: Array.isArray(patch.actions) && patch.actions.length ? patch.actions : ['generate'],
+ };
+ if (patch.aspect) {
+ keep.aspect = patch.aspect;
+ }
+ if (Array.isArray(patch.loras)) {
+ keep.loras = patch.loras;
+ }
+ if (patch.width != null) {
+ keep.width = patch.width;
+ }
+ if (patch.height != null) {
+ keep.height = patch.height;
+ }
+ return (
+ 'You are the Krea 2 prompt prep step (chat model). Rewrite SOURCE into the final Swarm Generate box text.\n'
+ + 'HARD RULES:\n'
+ + '- JSON "prompt": English only (no Cyrillic) — translate if needed.\n'
+ + '- Natural photographer/director prose for Qwen3-VL — not Danbooru tags, not (word:1.5), not masterpiece/best quality/8k.\n'
+ + '- Structure & front-load: subject → pose/action → body/wardrobe → setting → materials/textures → camera/framing → lighting → medium/mood.\n'
+ + '- Expand thin ideas; fix anti-patterns; one coherent scene.\n'
+ + '- Keep LoRA trigger phrases in English near the subject they affect.\n'
+ + '- Prefer positives over negatives; preserve meaning and NSFW level from SOURCE.\n'
+ + '- One short ack in the user language max, then ONE fenced JSON merging these keys: '
+ + `${JSON.stringify(keep)} plus the new English "prompt".\n`
+ + '- Include actions:["generate"] when an image was requested.\n\n'
+ + `SOURCE:\n${patch.prompt}`
+ );
+ }
+
+ function mergePromptEnRewrite(effective) {
+ const base = state.pendingPromptEnMerge;
+ state.pendingPromptEnMerge = null;
+ if (!base || !effective) {
+ return effective;
+ }
+ return {
+ ...base,
+ ...effective,
+ prompt: effective.prompt || base.prompt,
+ actions: (Array.isArray(effective.actions) && effective.actions.length)
+ ? effective.actions
+ : (base.actions || ['generate']),
+ loras: effective.loras || base.loras,
+ aspect: effective.aspect || base.aspect,
+ };
+ }
+
+ /** «запомни как базовый промпт» — apply/save only, never Generate / auto look_at. */
+ function userAsksNoGenerate(text) {
+ const t = String(text || '').trim();
+ if (!t || userAsksGenerate(t)) {
+ return false;
+ }
+ if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
+ return true;
+ }
+ return cyrTokenRe(
+ 'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
+ + 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
+ + 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
+ + 'не\\s+рисуй|не\\s+запускай\\s+генер',
+ ).test(t);
+ }
+
+ function stripGenerateAction(patch) {
+ if (!patch || typeof patch !== 'object') {
+ return patch;
+ }
+ if (!Array.isArray(patch.actions)) {
+ return patch;
+ }
+ const next = patch.actions.map(String).filter((a) => a !== 'generate');
+ if (next.length === patch.actions.length) {
+ return patch;
+ }
+ const out = { ...patch };
+ if (next.length) {
+ out.actions = next;
+ } else {
+ delete out.actions;
+ }
+ return out;
+ }
+
+ function stripLookAt(patch) {
+ if (!patch || typeof patch !== 'object') {
+ return patch;
+ }
+ if (patch.look_at == null && patch.vision_from == null && patch.vision_slots == null) {
+ return patch;
+ }
+ const out = { ...patch };
+ delete out.look_at;
+ delete out.vision_from;
+ delete out.vision_slots;
+ return out;
+ }
+
+ function rememberLastPatch(patch) {
+ if (patch && typeof patch === 'object' && !isCardObject(patch)) {
+ state.lastPatch = patch;
+ syncBuildGenButton();
+ }
+ }
+
+ function syncBuildGenButton() {
+ const btn = $('sa_btn_build_gen');
+ if (!btn) {
+ return;
+ }
+ if (state.lastPatch) {
+ const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null).slice(0, 6);
+ btn.title = `Есть патч Assistent (${keys.join(', ') || '…'}) → Apply + Generate`;
+ btn.classList.add('sa-has-patch');
+ } else {
+ btn.title = 'Нет патча — Generate с текущим промптом SwarmUI';
+ btn.classList.remove('sa-has-patch');
+ }
+ }
+
+ function defaultPackId() {
+ return state.config?.assistant?.default_pack
+ || $('sa_pack')?.querySelector('option')?.value
+ || 'ordinary';
+ }
+
+ function syncModeBadge() {
+ const badge = $('sa_mode_badge');
+ const pack = $('sa_pack')?.value || defaultPackId();
+ if (!badge) {
+ return;
+ }
+ const shortMap = {
+ ordinary: 'обычный',
+ write_prompt: 'write',
+ critique_image: 'critique',
+ compose_scene: 'compose',
+ fix_params: 'params',
+ inpaint_edit: 'inpaint',
+ describe_ref: 'describe',
+ catalog_card: 'card',
+ author_persona: 'persona',
+ };
+ const short = shortMap[pack] || pack.replace(/_/g, ' ').slice(0, 12);
+ badge.textContent = short;
+ badge.dataset.pack = pack;
+ badge.title = `Режим: ${pack}`;
+ badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit');
+ }
+
+ function syncLiveParamsBar() {
+ const el = $('sa_live_params');
+ if (!el) {
+ return;
+ }
+ const w = parseInt(val('input_width') || '0', 10) || null;
+ const h = parseInt(val('input_height') || '0', 10) || null;
+ const aspect = guessAspectFromSize(w, h) || '—';
+ const steps = val('input_steps') || '—';
+ const cfg = val('input_cfgscale') || val('input_cfg') || '—';
+ const seed = val('input_seed') || '—';
+ const profile = detectKreaProfileName();
+ el.textContent = `${aspect} · ${w || '?'}×${h || '?'} · steps ${steps} · cfg ${cfg} · ${profile} · seed ${seed}`;
+ }
+
+ function applyAspectTableFrom(obj) {
+ if (!obj || typeof obj !== 'object') {
+ return false;
+ }
+ const next = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (Array.isArray(v) && v.length >= 2) {
+ next[k] = [Number(v[0]), Number(v[1])];
+ }
+ }
+ if (!Object.keys(next).length) {
+ return false;
+ }
+ ASPECT_TABLE = next;
+ return true;
+ }
+
+ function resolveExactBundle() {
+ const exact = state.exact || state.config?.exact || {};
+ const profiles = exact.profiles || state.kreaProfiles || {};
+ return { exact, profiles };
+ }
+
+ function detectKreaProfileName() {
+ try {
+ const model = resolveCurrentCheckpoint();
+ const blob = `${model?.name || ''} ${model?.title || ''}`.toLowerCase();
+ const hasRaw = /\braw\b/.test(blob);
+ const hasTurbo = /\bturbo\b/.test(blob);
+ return hasRaw && !hasTurbo ? 'raw' : 'turbo';
+ } catch (e) {
+ return (state.exact?.generation?.profile) || 'turbo';
+ }
+ }
+
+ function mergedGenerationDefaults(profileName) {
+ const { exact, profiles } = resolveExactBundle();
+ const gen = exact.generation && typeof exact.generation === 'object' ? { ...exact.generation } : {};
+ const profile = profileName || gen.profile || detectKreaProfileName();
+ const fromProfile = profiles[profile] && typeof profiles[profile] === 'object' ? { ...profiles[profile] } : {};
+ const session = state.sessionExact && typeof state.sessionExact === 'object' ? { ...state.sessionExact } : {};
+ // Profile (turbo/raw) overrides generation defaults; session overrides both.
+ return { ...gen, ...fromProfile, profile, ...session };
+ }
+
+ function exactDefaultFor(key, profileName) {
+ const { exact, profiles } = resolveExactBundle();
+ const profile = profileName || exact.generation?.profile || detectKreaProfileName();
+ const fromProfile = profiles[profile]?.[key];
+ if (fromProfile != null) {
+ return fromProfile;
+ }
+ return exact.generation?.[key];
+ }
+
+ function rememberSessionExact(partial) {
+ if (state.restoringChat || !partial || typeof partial !== 'object') {
+ return;
+ }
+ const keys = ['steps', 'cfg', 'sigma_shift', 'aspect', 'width', 'height', 'images', 'batch', 'seed', 'sampler', 'scheduler'];
+ for (const k of keys) {
+ if (partial[k] != null) {
+ state.sessionExact[k] = partial[k];
+ }
+ }
+ if (partial.images == null && partial.batch != null) {
+ state.sessionExact.images = partial.batch;
+ }
+ }
+
+ function fillEmptyParamsFromExact() {
+ const defaults = mergedGenerationDefaults();
+ if (isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) {
+ setVal('input_steps', String(defaults.steps));
+ }
+ const cfgRaw = val('input_cfgscale') || val('input_cfg');
+ if (isEmptyParamField(cfgRaw, { treatZeroEmpty: true }) && defaults.cfg != null) {
+ if (document.getElementById('input_cfgscale')) {
+ setVal('input_cfgscale', String(defaults.cfg));
+ } else if (document.getElementById('input_cfg')) {
+ setVal('input_cfg', String(defaults.cfg));
+ }
+ }
+ if (isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
+ setVal('input_sigmashift', String(defaults.sigma_shift));
+ }
+ const wEmpty = isEmptyParamField(val('input_width'), { treatZeroEmpty: true });
+ const hEmpty = isEmptyParamField(val('input_height'), { treatZeroEmpty: true });
+ if ((wEmpty || hEmpty) && defaults.aspect) {
+ const size = sizeFromAspect(defaults.aspect);
+ if (size) {
+ if (wEmpty) {
+ setVal('input_width', String(size[0]));
+ }
+ if (hEmpty) {
+ setVal('input_height', String(size[1]));
+ }
+ }
+ } else {
+ if (wEmpty && defaults.width != null) {
+ setVal('input_width', String(defaults.width));
+ }
+ if (hEmpty && defaults.height != null) {
+ setVal('input_height', String(defaults.height));
+ }
+ }
+ const batchId = document.getElementById('input_images') ? 'input_images' : (document.getElementById('input_batchsize') ? 'input_batchsize' : null);
+ if (batchId && isEmptyParamField(val(batchId), { treatZeroEmpty: true })) {
+ const batch = defaults.images != null ? defaults.images : defaults.batch;
+ if (batch != null) {
+ setVal(batchId, String(batch));
+ }
+ }
+ }
+
+ function shouldSkipSessionRollback(key, patchValue) {
+ if (state.restoringChat) {
+ return false;
+ }
+ if (state.lastUserParamIntent) {
+ return false;
+ }
+ if (state.sessionExact[key] == null) {
+ return false;
+ }
+ const sessionVal = state.sessionExact[key];
+ if (String(sessionVal) === String(patchValue)) {
+ return false;
+ }
+ const exactVal = exactDefaultFor(key);
+ if (exactVal == null) {
+ return false;
+ }
+ // Model trying to restore file exact while session override differs — keep session.
+ return String(patchValue) === String(exactVal);
+ }
+
+ function openAssistentTab() {
+ const tab = document.getElementById(TAB_BUTTON_ID);
+ if (tab) {
+ tab.click();
+ setTimeout(() => $('sa_input')?.focus(), 50);
+ return true;
+ }
+ const pane = document.getElementById('assistent');
+ if (pane && typeof bootstrap !== 'undefined' && bootstrap.Tab) {
+ try {
+ bootstrap.Tab.getOrCreateInstance(tab || pane).show();
+ } catch (e) { /* ignore */ }
+ }
+ setTimeout(() => $('sa_input')?.focus(), 50);
+ return !!tab;
+ }
+
+ function historyMessageLimit() {
+ const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
+ return turns * 2;
+ }
+
+ function flashImagePane(slotId) {
+ const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
+ if (!el) {
+ return;
+ }
+ el.classList.remove('sa-flash');
+ void el.offsetWidth;
+ el.classList.add('sa-flash');
+ }
+
+ function ensureBoard() {
+ if (state.slots.length) {
+ return;
+ }
+ state.slots = [
+ { id: GEN_ID, type: 'generate', label: 'Generate', src: null, attach: false },
+ { id: 'ref1', type: 'ref', label: 'Ref 1', src: null, attach: true },
+ ];
+ state.refSeq = 1;
+ state.selectedSlotId = 'ref1';
+ }
+
+ function slotById(id) {
+ ensureBoard();
+ const key = normalizeSlotId(id);
+ return state.slots.find((s) => s.id === key) || null;
+ }
+
+ function generateSlot() {
+ return slotById(GEN_ID);
+ }
+
+ function refSlots() {
+ ensureBoard();
+ return state.slots.filter((s) => s.type === 'ref');
+ }
+
+ function normalizeSlotId(id) {
+ const raw = String(id || '').trim().toLowerCase();
+ if (!raw) {
+ return '';
+ }
+ if (raw === 'gen' || raw === 'current' || raw === 'live' || raw === 'generation') {
+ return GEN_ID;
+ }
+ if (raw === 'selected' || raw === 'sel') {
+ return state.selectedSlotId;
+ }
+ const m = raw.match(/^ref\s*[_-]?\s*(\d+)$/);
+ if (m) {
+ return `ref${m[1]}`;
+ }
+ return raw;
+ }
+
+ function selectedSlot() {
+ return slotById(state.selectedSlotId) || generateSlot();
+ }
+
+ function selectedSrc() {
+ return selectedSlot()?.src || null;
+ }
+
+ function syncLastImageAlias() {
+ const attached = attachableSlots();
+ state.lastImageDataUrl = (attached[0] || selectedSlot() || generateSlot())?.src || null;
+ }
+
+ function attachableSlots() {
+ ensureBoard();
+ return state.slots.filter((s) => s.attach && s.src);
+ }
+
+ /** Real board frames (not model previews). Used for has_vision_image even when JPEG is not sent. */
+ function visionReadySlots() {
+ ensureBoard();
+ return state.slots.filter((s) => s && s.src && !looksLikeModelPreview(s.src));
+ }
+
+ function setSlotSrc(id, src, { select = true, attach = null, note = null, switchTab = false, allowPreview = false } = {}) {
+ const slot = slotById(id);
+ if (!slot) {
+ return false;
+ }
+ const cleaned = src ? String(src).trim().split(/\s+/)[0] : null;
+ if (cleaned && cleaned.startsWith('#')) {
+ return false;
+ }
+ if (cleaned && !allowPreview && looksLikeModelPreview(cleaned)) {
+ setStatus('Пропуск превью модели (нужна реальная генерация)');
+ return false;
+ }
+ slot.src = cleaned || null;
+ if (attach != null) {
+ slot.attach = !!attach;
+ } else if (slot.type === 'ref' && slot.src) {
+ slot.attach = true;
+ }
+ if (select) {
+ state.selectedSlotId = slot.id;
+ }
+ syncLastImageAlias();
+ renderBoard();
+ flashImagePane(slot.id);
+ if (switchTab) {
+ openAssistentTab();
+ }
+ if (note) {
+ setStatus(note);
+ }
+ return true;
+ }
+
+ function addRefSlot({ src = null, select = true } = {}) {
+ ensureBoard();
+ if (refSlots().length >= MAX_REF_SLOTS) {
+ setStatus(`Max ${MAX_REF_SLOTS} reference windows`);
+ const empty = refSlots().find((s) => !s.src);
+ if (empty && src) {
+ return setSlotSrc(empty.id, src, { select, note: `Loaded into ${empty.label}` });
+ }
+ return empty || null;
+ }
+ state.refSeq += 1;
+ const id = `ref${state.refSeq}`;
+ const slot = {
+ id,
+ type: 'ref',
+ label: `Ref ${state.refSeq}`,
+ src: src || null,
+ attach: !!src,
+ };
+ state.slots.push(slot);
+ if (select) {
+ state.selectedSlotId = id;
+ }
+ renderBoard();
+ return slot;
+ }
+
+ function clearSlot(id, { silent = false } = {}) {
+ const slot = slotById(id);
+ if (!slot) {
+ return;
+ }
+ if (slot.type === 'generate') {
+ if (!silent) {
+ setStatus('Generate window is live — use Snapshot gen to copy it');
+ }
+ return;
+ }
+ slot.src = null;
+ slot.attach = true;
+ syncLastImageAlias();
+ renderBoard();
+ if (!silent) {
+ setStatus(`${slot.label} cleared`);
+ }
+ }
+
+ function snapshotGenerateToRef() {
+ // Never promote checkpoint/LoRA card previews into Refs.
+ const src = generateSlot()?.src && !looksLikeModelPreview(generateSlot().src)
+ ? generateSlot().src
+ : findCurrentGenerateSrc({ allowPreview: false });
+ if (!src) {
+ setStatus('Нет текущего кадра Generate (превью модели не считается)');
+ return false;
+ }
+ const empty = refSlots().find((s) => !s.src);
+ let ok = false;
+ if (empty) {
+ ok = setSlotSrc(empty.id, src, { note: `Снимок → ${empty.label}` });
+ } else {
+ const created = addRefSlot({ src, select: true });
+ if (created?.src) {
+ setStatus(`Снимок → ${created.label}`);
+ flashImagePane(created.id);
+ ok = true;
+ } else {
+ const last = refSlots()[refSlots().length - 1];
+ if (last) {
+ ok = setSlotSrc(last.id, src, { note: `Снимок → ${last.label} (замена)` });
+ }
+ }
+ }
+ if (ok) {
+ setBoardTab('refs');
+ }
+ return ok;
+ }
+
+ function putImageOnBoard(src, { note = null, switchTab = false, preferSelected = true } = {}) {
+ if (!src) {
+ return false;
+ }
+ ensureBoard();
+ const sel = selectedSlot();
+ if (preferSelected && sel && sel.type === 'ref') {
+ return setSlotSrc(sel.id, src, { note: note || `Loaded into ${sel.label}`, switchTab });
+ }
+ const empty = refSlots().find((s) => !s.src);
+ if (empty) {
+ return setSlotSrc(empty.id, src, { note: note || `Loaded into ${empty.label}`, switchTab });
+ }
+ const created = addRefSlot({ src, select: true });
+ if (created) {
+ if (switchTab) {
+ openAssistentTab();
+ }
+ if (note) {
+ setStatus(note);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ function setImageFromSrc(src, opts = {}) {
+ return putImageOnBoard(src, opts);
+ }
+
+ function clearVisionImage(opts) {
+ clearSlot(state.selectedSlotId, opts);
+ }
+
+ function slotCatalog() {
+ ensureBoard();
+ return state.slots.map((s) => ({
+ id: s.id,
+ type: s.type,
+ label: s.label,
+ has_image: !!s.src,
+ attach: !!s.attach,
+ selected: s.id === state.selectedSlotId,
+ }));
+ }
+
+ function lookAtIdsFromPatch(patch) {
+ if (!patch) {
+ return [];
+ }
+ const raw = patch.look_at || patch.vision_from || patch.vision_slots;
+ const list = Array.isArray(raw) ? raw : (raw ? [raw] : []);
+ if (Array.isArray(patch.actions)) {
+ for (const a of patch.actions.map(String)) {
+ const m = a.match(/^look_at[_:]?(generate|ref\d+|selected)$/i);
+ if (m) {
+ list.push(m[1]);
+ }
+ }
+ }
+ return [...new Set(list.map(normalizeSlotId).filter(Boolean))];
+ }
+
+ function resolveSlotSrc(id) {
+ if (!id) {
+ return selectedSrc() || generateSlot()?.src || findCurrentGenerateSrc();
+ }
+ const slot = slotById(id);
+ if (slot?.src) {
+ return slot.src;
+ }
+ if (normalizeSlotId(id) === GEN_ID) {
+ return findCurrentGenerateSrc();
+ }
+ return null;
+ }
+
+ function isSwarmGenerateRunning() {
+ try {
+ if (typeof num_live_gens === 'number' && num_live_gens > 0) {
+ return true;
+ }
+ if (typeof num_waiting_gens === 'number' && num_waiting_gens > 0) {
+ return true;
+ }
+ } catch (e) { /* ignore */ }
+ try {
+ if (typeof mainGenHandler !== 'undefined' && mainGenHandler) {
+ if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) {
+ return true;
+ }
+ }
+ } catch (e) { /* ignore */ }
+ const interrupt = document.getElementById('interrupt_button')
+ || document.getElementById('alt_interrupt_button');
+ if (interrupt && !interrupt.hidden && interrupt.offsetParent !== null) {
+ return true;
+ }
+ const genBtn = document.getElementById('generate_button') || document.getElementById('alt_generate_button');
+ if (genBtn && (genBtn.disabled || /interrupt/i.test(genBtn.textContent || ''))) {
+ return true;
+ }
+ return false;
+ }
+
+ function isGenerateUnavailable() {
+ if (state.generating || state.busy) {
+ return true;
+ }
+ return isSwarmGenerateRunning();
+ }
+
+ function syncGenerateBusy() {
+ const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
+ if (overlay) {
+ // If Swarm already finished but our waiter is stuck on same URL, drop the overlay
+ // as soon as the live frame is on the board.
+ const stuck = state.generating && !isSwarmGenerateRunning();
+ overlay.hidden = (!state.generating && state.busyPhase !== 'generating') || stuck;
+ }
+ const running = state.generating || state.busyPhase === 'generating';
+ document.querySelectorAll('.sa-slot-gen-result').forEach((el) => {
+ const busy = el.querySelector('.sa-slot-busy');
+ if (!busy) {
+ return;
+ }
+ const hasImg = el.classList.contains('sa-has-image');
+ busy.hidden = !running || hasImg;
+ });
+ }
+
+ function syncPatchActionAvailability() {
+ const bar = document.querySelector('.sa-patch-actions.sa-patch-current');
+ if (!bar) {
+ return;
+ }
+ const locked = isGenerateUnavailable();
+ bar.querySelectorAll('.sa-btn-gen').forEach((btn) => {
+ btn.disabled = locked;
+ let spin = btn.querySelector('.sa-spinner');
+ if (locked) {
+ if (!spin) {
+ spin = document.createElement('span');
+ spin.className = 'sa-spinner sa-spinner-btn';
+ spin.setAttribute('aria-hidden', 'true');
+ btn.prepend(spin);
+ }
+ } else if (spin) {
+ spin.remove();
+ }
+ });
+ }
+
+ function retireStalePatchActions() {
+ document.querySelectorAll('.sa-patch-actions').forEach((el) => {
+ const note = document.createElement('div');
+ note.className = 'sa-patch-stale';
+ note.textContent = 'Superseded — use the latest proposal';
+ el.replaceWith(note);
+ });
+ }
+
+ function mountPatchBlock(host, patch, { silent = false } = {}) {
+ if (!host || !patch) {
+ return;
+ }
+ rememberLastPatch(patch);
+ const wrap = document.createElement('div');
+ wrap.className = 'sa-patch' + (silent ? ' sa-patch-auto' : '');
+ const details = document.createElement('details');
+ details.className = 'sa-patch-details';
+ const summary = document.createElement('summary');
+ const keys = Object.keys(patch).filter((k) => patch[k] != null && k !== 'notes' && k !== 'actions');
+ summary.textContent = silent
+ ? `Патч применён · ${keys.slice(0, 6).join(', ') || 'generate'}`
+ : `JSON патч · ${keys.slice(0, 8).join(', ') || '…'}`;
+ const pre = document.createElement('pre');
+ pre.textContent = JSON.stringify(patch, null, 2);
+ details.appendChild(summary);
+ details.appendChild(pre);
+ wrap.appendChild(details);
+ mountPatchActions(wrap, patch, { silent });
+ host.appendChild(wrap);
+ }
+
+ function mountPatchActions(parent, patch, { silent = false } = {}) {
+ if (!parent || !patch) {
+ return;
+ }
+ rememberLastPatch(patch);
+ retireStalePatchActions();
+ const wrap = parent.classList.contains('sa-patch') ? parent : null;
+ const host = wrap || parent;
+ if (silent) {
+ const note = document.createElement('div');
+ note.className = 'sa-patch-actions sa-patch-silent sa-patch-current';
+ const willGen = Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')
+ || !!state.pendingSilentGen;
+ note.textContent = willGen
+ ? 'Применено автоматически · Generate…'
+ : 'Применено автоматически';
+ host.appendChild(note);
+ return;
+ }
+ const actions = document.createElement('div');
+ actions.className = 'sa-patch-actions sa-patch-current';
+ for (const [label, which] of [
+ ['Применить всё', 'all'],
+ ['Промпт', 'prompt'],
+ ['LoRAs', 'loras'],
+ ['Параметры', 'params'],
+ ]) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'basic-button';
+ btn.textContent = label;
+ btn.addEventListener('click', () => applyPatch(patch, which));
+ actions.appendChild(btn);
+ }
+ const genBtn = document.createElement('button');
+ genBtn.type = 'button';
+ genBtn.className = 'basic-button sa-btn-gen';
+ genBtn.textContent = 'Применить + Generate';
+ genBtn.addEventListener('click', async () => {
+ if (isGenerateUnavailable()) {
+ return;
+ }
+ startBusyUi('silent_gen');
+ await applyPatch(patch, 'all');
+ await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
+ });
+ actions.appendChild(genBtn);
+ host.appendChild(actions);
+ syncPatchActionAvailability();
+ }
+
+ async function buildCurrentAndGenerate() {
+ if (state.busy || state.generating) {
+ setStatus('Занято — подожди или нажми Стоп');
+ return;
+ }
+ if (isGenerateUnavailable()) {
+ setStatus('Generate недоступен — дождись SwarmUI');
+ return;
+ }
+ const patch = state.lastPatch;
+ if (patch) {
+ startBusyUi('silent_gen');
+ setStatus('Собираю патч → Generate…');
+ await applyPatch(patch, 'all');
+ syncLiveParamsBar();
+ await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
+ return;
+ }
+ startBusyUi('generating');
+ setStatus('Generate с текущим промптом…');
+ await runGenerateFromPatch({ actions: ['generate'] }, { force: true });
+ }
+
+ function renderBoard() {
+ const board = $('sa_board');
+ if (!board) {
+ return;
+ }
+ ensureBoard();
+ const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
+ const refs = refSlots();
+ const showVariantGrid = tab === 'generate' && (state.genResults || []).length > 1;
+ board.classList.toggle('sa-board-many', (tab === 'refs' && (refs.some((s) => s.src) || refs.length > 1)) || showVariantGrid);
+ board.classList.toggle('sa-board-gen-only', tab === 'generate' && !showVariantGrid);
+ board.classList.toggle('sa-board-variants', showVariantGrid);
+ board.innerHTML = '';
+ if (tab === 'generate' && showVariantGrid) {
+ for (const row of state.genResults) {
+ board.appendChild(buildGenResultEl(row));
+ }
+ } else {
+ const toShow = tab === 'generate'
+ ? state.slots.filter((s) => s.type === 'generate')
+ : state.slots.filter((s) => s.type !== 'generate');
+ for (const slot of toShow) {
+ board.appendChild(buildSlotEl(slot));
+ }
+ }
+ if (tab === 'refs' && refs.length < MAX_REF_SLOTS) {
+ const add = document.createElement('div');
+ add.className = 'sa-add-cell';
+ add.textContent = '+ Ref';
+ add.title = 'Добавить окно референса';
+ add.addEventListener('click', (e) => {
+ e.stopPropagation();
+ addRefSlot({ select: true });
+ });
+ add.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ });
+ add.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ const created = addRefSlot({ select: true });
+ if (created) {
+ state.selectedSlotId = created.id;
+ await handleDropDataTransfer(e.dataTransfer, created.id);
+ }
+ });
+ board.appendChild(add);
+ }
+ syncBoardChrome();
+ syncGenerateBusy();
+ syncLastImageAlias();
+ }
+
+ function buildGenResultEl(row) {
+ const el = document.createElement('div');
+ el.className = 'sa-slot sa-slot-gen-result';
+ el.dataset.genResultId = row.id;
+ if (row.src) {
+ el.classList.add('sa-has-image');
+ }
+ if (row.id === state.selectedGenResultId) {
+ el.classList.add('sa-selected');
+ }
+ const bar = document.createElement('div');
+ bar.className = 'sa-slot-bar';
+ const chip = document.createElement('span');
+ chip.className = 'sa-slot-chip sa-live';
+ chip.textContent = row.label || row.id;
+ bar.appendChild(chip);
+ const openBtn = document.createElement('button');
+ openBtn.type = 'button';
+ openBtn.className = 'sa-slot-open';
+ openBtn.textContent = 'Открыть';
+ openBtn.title = 'Просмотр';
+ openBtn.hidden = !row.src;
+ openBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ selectGenResult(row.id, { restore: true, openViewer: true });
+ });
+ bar.appendChild(openBtn);
+ el.appendChild(bar);
+
+ if (row.src) {
+ const img = document.createElement('img');
+ img.alt = row.label || row.id;
+ img.src = row.src;
+ el.appendChild(img);
+ } else {
+ const empty = document.createElement('div');
+ empty.className = 'sa-image-empty';
+ empty.innerHTML = '…
Жду кадр
';
+ el.appendChild(empty);
+ }
+
+ const busy = document.createElement('div');
+ busy.className = 'sa-slot-busy';
+ const pending = !row.src && (state.generating || state.busyPhase === 'generating');
+ busy.hidden = !pending;
+ busy.innerHTML = ' ';
+ el.appendChild(busy);
+
+ el.addEventListener('click', () => {
+ const already = row.id === state.selectedGenResultId;
+ selectGenResult(row.id, { restore: true, openViewer: already && !!row.src });
+ });
+ el.addEventListener('dblclick', (e) => {
+ e.preventDefault();
+ if (row.src) {
+ selectGenResult(row.id, { restore: true, openViewer: true });
+ }
+ });
+ return el;
+ }
+
+ function ensureGenLightbox() {
+ let root = $('sa_gen_lightbox');
+ if (root) {
+ return root;
+ }
+ const host = $('swarm_assistent_root') || document.body;
+ root = document.createElement('div');
+ root.id = 'sa_gen_lightbox';
+ root.className = 'sa-lightbox';
+ root.hidden = true;
+ root.innerHTML = `
+
+
+
+
+
+ ✕
+
+
+
‹
+
+
›
+
+
+ В Refs
+ Как Init
+ Закрыть
+
+
`;
+ host.appendChild(root);
+ root.addEventListener('click', async (e) => {
+ const act = e.target?.closest?.('[data-lb]')?.getAttribute('data-lb');
+ if (!act) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ if (act === 'close') {
+ closeGenLightbox();
+ } else if (act === 'prev') {
+ stepGenLightbox(-1);
+ } else if (act === 'next') {
+ stepGenLightbox(1);
+ } else if (act === 'to_ref') {
+ const row = currentLightboxRow();
+ if (row?.src) {
+ const created = addRefSlot({ select: true });
+ if (created) {
+ created.src = row.src;
+ setBoardTab('refs');
+ renderBoard();
+ setStatus(`Снимок → ${created.label}`);
+ }
+ }
+ } else if (act === 'as_init') {
+ const row = currentLightboxRow();
+ if (row?.src) {
+ await setInitFromSrc(row.src);
+ }
+ }
+ });
+ return root;
+ }
+
+ function currentLightboxRow() {
+ const list = (state.genResults || []).filter((r) => r.src);
+ if (!list.length || state.lightboxIndex < 0) {
+ return null;
+ }
+ return list[state.lightboxIndex] || null;
+ }
+
+ function syncGenLightbox() {
+ const root = ensureGenLightbox();
+ const list = (state.genResults || []).filter((r) => r.src);
+ const row = list[state.lightboxIndex];
+ if (!row) {
+ root.hidden = true;
+ return;
+ }
+ root.hidden = false;
+ const img = $('sa_lb_img');
+ const title = $('sa_lb_title');
+ const idx = $('sa_lb_idx');
+ if (img) {
+ img.src = row.src;
+ img.alt = row.label || row.id;
+ }
+ if (title) {
+ title.textContent = row.label || row.id;
+ }
+ if (idx) {
+ idx.textContent = `${state.lightboxIndex + 1} / ${list.length}`;
+ }
+ }
+
+ function openGenLightbox(id) {
+ const list = (state.genResults || []).filter((r) => r.src);
+ let idx = list.findIndex((r) => r.id === id);
+ if (idx < 0) {
+ idx = 0;
+ }
+ if (!list.length) {
+ return;
+ }
+ state.lightboxIndex = idx;
+ ensureGenLightbox();
+ syncGenLightbox();
+ selectGenResult(list[idx].id, { restore: true, openViewer: false });
+ }
+
+ function closeGenLightbox() {
+ state.lightboxIndex = -1;
+ const root = $('sa_gen_lightbox');
+ if (root) {
+ root.hidden = true;
+ }
+ }
+
+ function stepGenLightbox(delta) {
+ const list = (state.genResults || []).filter((r) => r.src);
+ if (list.length < 2) {
+ return;
+ }
+ state.lightboxIndex = (state.lightboxIndex + delta + list.length) % list.length;
+ const row = list[state.lightboxIndex];
+ if (row) {
+ selectGenResult(row.id, { restore: true, openViewer: false });
+ }
+ syncGenLightbox();
+ }
+
+ function syncBoardChrome() {
+ const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
+ $('sa_board_tab_gen')?.classList.toggle('sa-board-tab-active', tab === 'generate');
+ $('sa_board_tab_refs')?.classList.toggle('sa-board-tab-active', tab === 'refs');
+ $('sa_board_tab_gen')?.setAttribute('aria-selected', tab === 'generate' ? 'true' : 'false');
+ $('sa_board_tab_refs')?.setAttribute('aria-selected', tab === 'refs' ? 'true' : 'false');
+ const addBtn = $('sa_btn_add_ref');
+ if (addBtn) {
+ addBtn.hidden = tab !== 'refs';
+ }
+ const maskBtn = $('sa_btn_as_mask');
+ const clearSlotBtn = $('sa_btn_clear_image');
+ if (maskBtn) {
+ maskBtn.hidden = tab !== 'refs';
+ }
+ if (clearSlotBtn) {
+ clearSlotBtn.hidden = tab !== 'refs';
+ }
+ const badge = $('sa_refs_badge');
+ if (badge) {
+ const refs = refSlots();
+ const withImg = refs.filter((s) => s.src).length;
+ const withVision = refs.filter((s) => s.src && s.attach).length;
+ if (withImg || withVision) {
+ badge.hidden = false;
+ badge.textContent = withVision ? `${withImg} · vision ${withVision}` : String(withImg);
+ } else {
+ badge.hidden = true;
+ }
+ }
+ let genBadge = $('sa_gen_badge');
+ if (!genBadge) {
+ const genTab = $('sa_board_tab_gen');
+ if (genTab) {
+ genBadge = document.createElement('span');
+ genBadge.id = 'sa_gen_badge';
+ genBadge.className = 'sa-board-badge';
+ genBadge.hidden = true;
+ genTab.appendChild(genBadge);
+ }
+ }
+ if (genBadge) {
+ const n = finishedGenResultCount();
+ if (n > 1) {
+ genBadge.hidden = false;
+ genBadge.textContent = String(n);
+ } else {
+ genBadge.hidden = true;
+ }
+ }
+ }
+
+ function setBoardTab(tab, { persist = true } = {}) {
+ state.boardTab = tab === 'refs' ? 'refs' : 'generate';
+ if (persist) {
+ try {
+ localStorage.setItem(LS_BOARD_TAB, state.boardTab);
+ } catch (e) { /* ignore */ }
+ }
+ renderBoard();
+ }
+
+ function buildSlotEl(slot) {
+ const el = document.createElement('div');
+ el.className = `sa-slot${slot.type === 'generate' ? ' sa-slot-gen' : ''}`;
+ el.dataset.id = slot.id;
+ if (slot.src) {
+ el.classList.add('sa-has-image');
+ }
+ if (slot.id === state.selectedSlotId) {
+ el.classList.add('sa-selected');
+ }
+ const bar = document.createElement('div');
+ bar.className = 'sa-slot-bar';
+ const chip = document.createElement('span');
+ chip.className = `sa-slot-chip${slot.type === 'generate' ? ' sa-live' : ''}`;
+ chip.textContent = slot.type === 'generate' ? 'Generate' : slot.label;
+ bar.appendChild(chip);
+ const attachLab = document.createElement('label');
+ attachLab.className = 'sa-slot-attach';
+ attachLab.title = 'Attach this window to the next chat (vision)';
+ const cb = document.createElement('input');
+ cb.type = 'checkbox';
+ cb.checked = !!slot.attach;
+ cb.addEventListener('click', (e) => e.stopPropagation());
+ cb.addEventListener('change', (e) => {
+ e.stopPropagation();
+ slot.attach = cb.checked;
+ syncLastImageAlias();
+ });
+ attachLab.appendChild(cb);
+ attachLab.appendChild(document.createTextNode(' vision'));
+ bar.appendChild(attachLab);
+ el.appendChild(bar);
+
+ if (slot.src) {
+ const img = document.createElement('img');
+ img.alt = slot.label;
+ img.src = slot.src;
+ el.appendChild(img);
+ } else {
+ const empty = document.createElement('div');
+ empty.className = 'sa-image-empty';
+ empty.innerHTML = slot.type === 'generate'
+ ? 'Generate
Живой просмотр текущей генерации
'
+ : 'Reference
Drop · paste · Снимок gen
';
+ 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 = ' ';
+ el.appendChild(busy);
+
+ el.addEventListener('click', () => {
+ state.selectedSlotId = slot.id;
+ renderBoard();
+ });
+ el.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ el.classList.add('sa-dragover');
+ if (e.dataTransfer) {
+ e.dataTransfer.dropEffect = 'copy';
+ }
+ });
+ el.addEventListener('dragleave', () => el.classList.remove('sa-dragover'));
+ el.addEventListener('drop', async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ el.classList.remove('sa-dragover');
+ const targetId = slot.type === 'generate' ? null : slot.id;
+ if (slot.type === 'generate') {
+ const created = addRefSlot({ select: true });
+ await handleDropDataTransfer(e.dataTransfer, created?.id);
+ setBoardTab('refs');
+ } else {
+ await handleDropDataTransfer(e.dataTransfer, targetId);
+ }
+ });
+ return el;
+ }
+
+ function syncGenerateSlot() {
+ const slot = generateSlot();
+ if (!slot) {
+ return;
+ }
+ // Drop checkpoint/LoRA card previews that slipped into the Generate pane.
+ if (scrubPreviewFromGenerateSlot()) {
+ renderBoard();
+ }
+ const src = findCurrentGenerateSrc();
+ if (src && src !== slot.src) {
+ slot.src = src;
+ const img = document.querySelector('.sa-slot-gen img');
+ const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
+ const frame = document.querySelector('.sa-slot-gen');
+ if (img) {
+ img.src = src;
+ } else if (frame) {
+ renderBoard();
+ return;
+ }
+ if (empty) {
+ empty.hidden = true;
+ }
+ frame?.classList.add('sa-has-image');
+ } else if (src && slot.src === src) {
+ // Same URL, possibly new bytes after overwrite — nudge once Swarm is idle.
+ if (state.generating && !isSwarmGenerateRunning()) {
+ const img = document.querySelector('.sa-slot-gen img');
+ if (img) {
+ const bump = src.includes('?') ? `${src}&sa_t=${Date.now()}` : `${src}?sa_t=${Date.now()}`;
+ img.src = bump;
+ }
+ }
+ } else if (!src && !slot.src) {
+ const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
+ const frame = document.querySelector('.sa-slot-gen');
+ const img = document.querySelector('.sa-slot-gen img');
+ if (img) {
+ img.remove();
+ }
+ if (empty) {
+ empty.hidden = false;
+ }
+ frame?.classList.remove('sa-has-image', 'sa-attached');
+ }
+ syncGenerateBusy();
+ syncPatchActionAvailability();
+ }
+
+ function maybeWelcome() {
+ if (localStorage.getItem(LS_WELCOMED) === '1') {
+ return;
+ }
+ if (!$('sa_messages')) {
+ return;
+ }
+ localStorage.setItem(LS_WELCOMED, '1');
+ const box = $('sa_messages');
+ hideChatEmpty();
+ const div = document.createElement('div');
+ div.className = 'sa-msg assistant sa-welcome';
+ div.innerHTML = WELCOME_HTML;
+ box.appendChild(div);
+ scrollMessagesToBottom({ force: true });
+ }
+
+ function chatUid() {
+ return `c_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function stripJsonFencesForHistory(content) {
+ return String(content || '')
+ .replace(/```(?:json)?\s*[\s\S]*?```/gi, '')
+ // Drop echoed critique templates / empty patch headers so the next turn doesn't copy them.
+ .replace(/###\s*Critique\b[\s\S]*?(?=###|$)/gi, '')
+ .replace(/###\s*JSON\s*Patch\b[\s\S]*$/gi, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+ }
+
+ function slimHistoryMessages(list) {
+ return (list || [])
+ .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish)
+ .slice(-MAX_CHAT_MSGS)
+ .map((m) => {
+ let content = String(m.content || '');
+ if (m.role === 'assistant') {
+ content = stripJsonFencesForHistory(content);
+ }
+ return {
+ role: m.role,
+ content: content.slice(0, 4000),
+ persona: m.persona || undefined,
+ pack: m.pack || undefined,
+ };
+ });
+ }
+
+ function titleFromMessages(messages) {
+ const u = (messages || []).find((m) => m.role === 'user' && m.content);
+ const t = String(u?.content || '').replace(/\s+/g, ' ').trim();
+ return t ? t.slice(0, 52) : 'Новый чат';
+ }
+
+ function snapshotChatParams() {
+ let loras = [];
+ try {
+ if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
+ loras = loraHelper.selected.map((l) => ({
+ name: l.name || l,
+ weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
+ }));
+ }
+ } catch (e) { /* ignore */ }
+ let lastPatch = null;
+ try {
+ lastPatch = state.lastPatch ? JSON.parse(JSON.stringify(state.lastPatch)) : null;
+ } catch (e) {
+ lastPatch = null;
+ }
+ let sessionExact = {};
+ try {
+ sessionExact = state.sessionExact && typeof state.sessionExact === 'object'
+ ? JSON.parse(JSON.stringify(state.sessionExact))
+ : {};
+ } catch (e) {
+ sessionExact = {};
+ }
+ return {
+ prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
+ negative: val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '',
+ width: parseInt(val('input_width') || '0', 10) || null,
+ height: parseInt(val('input_height') || '0', 10) || null,
+ steps: parseInt(val('input_steps') || '0', 10) || null,
+ cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
+ sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
+ seed: val('input_seed') || null,
+ sampler: val('input_sampler') || null,
+ scheduler: val('input_scheduler') || null,
+ batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
+ loras,
+ persona: $('sa_persona')?.value || 'neutral',
+ pack: $('sa_pack')?.value || defaultPackId(),
+ sessionExact,
+ lastPatch,
+ genResults: Array.isArray(state.genResults)
+ ? state.genResults.map((r) => ({
+ id: r.id,
+ label: r.label,
+ src: r.src || null,
+ patch: r.patch || null,
+ }))
+ : [],
+ selectedGenResultId: state.selectedGenResultId || null,
+ };
+ }
+
+ async function restoreChatParams(params) {
+ state.restoringChat = true;
+ try {
+ // Always reset chat-scoped state so previous chat cannot leak.
+ state.sessionExact = {};
+ state.lastPatch = null;
+ state.lastUserParamIntent = false;
+
+ if (!params || typeof params !== 'object') {
+ clearGenResults();
+ syncBuildGenButton();
+ syncLiveParamsBar();
+ syncModeBadge();
+ renderBoard();
+ return { restored: false };
+ }
+
+ const promptBox = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
+ if (promptBox) {
+ promptBox.value = params.prompt != null ? String(params.prompt) : '';
+ promptBox.dispatchEvent(new Event('input', { bubbles: true }));
+ promptBox.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ setVal('input_negativeprompt', params.negative != null ? String(params.negative) : '');
+ // Force-write numerics when present so prior chat values cannot stick.
+ if (params.width != null) {
+ setVal('input_width', String(params.width));
+ }
+ if (params.height != null) {
+ setVal('input_height', String(params.height));
+ }
+ if (params.steps != null) {
+ setVal('input_steps', String(params.steps));
+ }
+ if (params.cfg != null) {
+ if (document.getElementById('input_cfgscale')) {
+ setVal('input_cfgscale', String(params.cfg));
+ } else {
+ setVal('input_cfg', String(params.cfg));
+ }
+ }
+ if (params.sigma_shift != null) {
+ setVal('input_sigmashift', String(params.sigma_shift));
+ }
+ if (params.seed != null && params.seed !== '') {
+ setVal('input_seed', String(params.seed));
+ }
+ if (params.sampler) {
+ setVal('input_sampler', String(params.sampler));
+ }
+ if (params.scheduler) {
+ setVal('input_scheduler', String(params.scheduler));
+ }
+ if (params.batch != null) {
+ if (document.getElementById('input_images')) {
+ setVal('input_images', String(params.batch));
+ } else if (document.getElementById('input_batchsize')) {
+ setVal('input_batchsize', String(params.batch));
+ }
+ }
+
+ const loras = Array.isArray(params.loras) ? params.loras : [];
+ await applyPatch({ loras }, 'loras');
+
+ if (params.pack) {
+ setPackValue(params.pack, { flash: false });
+ }
+ if (params.persona) {
+ await applyPersonaForChat(params.persona, { quiet: true });
+ }
+ state.sessionExact = params.sessionExact && typeof params.sessionExact === 'object'
+ ? { ...params.sessionExact }
+ : {};
+ state.lastPatch = params.lastPatch || null;
+ if (Array.isArray(params.genResults) && params.genResults.length) {
+ state.genResults = params.genResults.map((r, i) => ({
+ id: r.id || `var${i + 1}`,
+ label: r.label || `Вариант ${i + 1}`,
+ src: r.src || null,
+ patch: r.patch || null,
+ }));
+ state.selectedGenResultId = params.selectedGenResultId
+ || state.genResults.find((r) => r.src)?.id
+ || state.genResults[0]?.id
+ || null;
+ const selected = state.genResults.find((r) => r.id === state.selectedGenResultId);
+ const gen = generateSlot();
+ if (gen && selected?.src) {
+ gen.src = selected.src;
+ }
+ } else {
+ clearGenResults();
+ }
+ syncBuildGenButton();
+ syncLiveParamsBar();
+ syncModeBadge();
+ renderLoraChips();
+ syncChipHighlight();
+ renderBoard();
+ return { restored: true };
+ } finally {
+ state.restoringChat = false;
+ }
+ }
+
+ function applyPersonaForChat(personaId, { quiet = false } = {}) {
+ const id = String(personaId || 'neutral').trim() || 'neutral';
+ return new Promise((resolve) => {
+ const sel = $('sa_persona');
+ if (sel && [...sel.options].some((o) => o.value === id)) {
+ sel.value = id;
+ }
+ if (!quiet) {
+ onPersonaChanged();
+ resolve();
+ return;
+ }
+ // Quiet: reload persona config/exact without chat spam or wiping sessionExact.
+ saveSettings();
+ if (typeof genericRequest !== 'function') {
+ resolve();
+ return;
+ }
+ const packKeep = $('sa_pack')?.value;
+ genericRequest(
+ 'AssistentGetConfig',
+ { persona: id },
+ (data) => {
+ applyConfigPayload(data, { applyDefaults: false });
+ if (sel && [...sel.options].some((o) => o.value === id)) {
+ sel.value = id;
+ }
+ if (packKeep) {
+ setPackValue(packKeep, { flash: false });
+ }
+ resolve();
+ },
+ 0,
+ () => resolve(),
+ );
+ });
+ }
+
+ function persistChatsStore() {
+ try {
+ const chats = (state.chats || [])
+ .filter((c) => c && c.id && (c.id === state.activeChatId || (c.messages || []).length > 0))
+ .slice()
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
+ .slice(0, MAX_CHATS)
+ .map((c) => ({
+ id: c.id,
+ title: c.title || 'Новый чат',
+ createdAt: c.createdAt || Date.now(),
+ updatedAt: c.updatedAt || Date.now(),
+ messages: slimHistoryMessages(c.messages || []),
+ params: c.params || null,
+ }));
+ state.chats = chats;
+ localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
+ saveActiveChatToDisk();
+ } catch (e) {
+ console.warn('Assistent: persist chats failed', e);
+ try {
+ // Quota fallback: keep fewer / shorter chats.
+ const slim = (state.chats || [])
+ .filter((c) => c && c.id && (c.id === state.activeChatId || (c.messages || []).length > 0))
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
+ .slice(0, 12)
+ .map((c) => ({
+ ...c,
+ messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
+ ...m,
+ content: String(m.content || '').slice(0, 1500),
+ })),
+ }));
+ state.chats = slim;
+ localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
+ saveActiveChatToDisk();
+ } catch (e2) {
+ console.warn('Assistent: chats quota fallback failed', e2);
+ }
+ }
+ }
+
+ /** Mirrors the active chat onto the data volume (debounced inside SA.persist). */
+ function saveActiveChatToDisk() {
+ const persist = diskPersist();
+ if (!persist || !state.activeChatId) {
+ return;
+ }
+ const chat = findChat(state.activeChatId);
+ if (!chat || !(chat.messages || []).length) {
+ return;
+ }
+ persist.saveChat(chat);
+ }
+
+ function loadChatsStore() {
+ state.chats = [];
+ try {
+ const raw = localStorage.getItem(LS_CHATS);
+ if (raw) {
+ const parsed = JSON.parse(raw);
+ if (Array.isArray(parsed?.chats)) {
+ state.chats = parsed.chats.filter((c) => c && c.id);
+ }
+ }
+ } catch (e) { /* ignore */ }
+ migrateLegacyHistoryIntoChats();
+ }
+
+ /** Disk wins over localStorage — chats follow the data volume, not the browser. */
+ async function loadChatsFromDisk() {
+ const persist = diskPersist();
+ if (!persist) {
+ return;
+ }
+ let chats = null;
+ try {
+ chats = await persist.loadChats();
+ } catch (e) {
+ console.warn('Assistent: disk chats failed', e);
+ return;
+ }
+ if (!Array.isArray(chats) || !chats.length) {
+ return;
+ }
+ state.chats = chats.filter((c) => c && c.id).slice(0, MAX_CHATS);
+ try {
+ localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: state.chats }));
+ } catch (e) { /* quota — disk is the source of truth anyway */ }
+ }
+
+ function migrateLegacyHistoryIntoChats() {
+ try {
+ const raw = localStorage.getItem(LS_HISTORY);
+ if (!raw) {
+ return;
+ }
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed) || !parsed.length) {
+ localStorage.removeItem(LS_HISTORY);
+ return;
+ }
+ const messages = slimHistoryMessages(parsed);
+ if (!messages.length) {
+ localStorage.removeItem(LS_HISTORY);
+ return;
+ }
+ const already = state.chats.some((c) =>
+ (c.messages || []).length === messages.length
+ && (c.messages[0]?.content || '') === (messages[0]?.content || ''));
+ if (!already) {
+ state.chats.unshift({
+ id: chatUid(),
+ title: titleFromMessages(messages),
+ createdAt: Date.now() - 1000,
+ updatedAt: Date.now() - 1000,
+ messages,
+ params: null,
+ });
+ persistChatsStore();
+ }
+ localStorage.removeItem(LS_HISTORY);
+ } catch (e) {
+ try { localStorage.removeItem(LS_HISTORY); } catch (e2) { /* ignore */ }
+ }
+ }
+
+ function findChat(id) {
+ return (state.chats || []).find((c) => c.id === id) || null;
+ }
+
+ function saveActiveChatToStore({ dropEmpty = false } = {}) {
+ if (!state.activeChatId || state.restoringChat) {
+ return;
+ }
+ const chat = findChat(state.activeChatId);
+ if (!chat) {
+ return;
+ }
+ chat.messages = slimHistoryMessages(state.history);
+ chat.params = snapshotChatParams();
+ chat.updatedAt = Date.now();
+ chat.title = titleFromMessages(chat.messages);
+ if (dropEmpty && !chat.messages.length) {
+ state.chats = state.chats.filter((c) => c.id !== chat.id);
+ if (state.activeChatId === chat.id) {
+ state.activeChatId = null;
+ }
+ }
+ persistChatsStore();
+ }
+
+ function resetMessagesUi(emptyHint) {
+ const box = $('sa_messages');
+ if (!box) {
+ return;
+ }
+ box.innerHTML = '';
+ const empty = document.createElement('div');
+ empty.className = 'sa-chat-empty';
+ empty.id = 'sa_chat_empty';
+ empty.innerHTML = emptyHint
+ || 'Новый чат
Параметры Generate остаются как сейчас.+ — ещё один чат · История — вернуться к прошлому (с его параметрами).
';
+ box.appendChild(empty);
+ }
+
+ function renderHistoryIntoUi(messages) {
+ const box = $('sa_messages');
+ if (!box) {
+ return;
+ }
+ box.innerHTML = '';
+ const list = slimHistoryMessages(messages);
+ if (!list.length) {
+ resetMessagesUi();
+ return;
+ }
+ for (const m of list) {
+ if (m.role === 'user') {
+ appendMessage('user', m.content, null, null, { historical: true });
+ } else {
+ appendMessage('assistant', m.content, null, null, {
+ persona: m.persona ? { id: m.persona, title: m.persona } : null,
+ pack: m.pack,
+ historical: true,
+ });
+ }
+ }
+ }
+
+ function updateSessionLabel() {
+ const el = $('sa_session_label');
+ if (!el) {
+ return;
+ }
+ const chat = findChat(state.activeChatId);
+ el.textContent = chat?.title || 'Новый чат';
+ el.title = (chat?.title || 'Новый чат') + ' — клик: История';
+ }
+
+ function savedChatsCount() {
+ return (state.chats || []).filter((c) => (c.messages || []).length > 0).length;
+ }
+
+ function syncHistoryBadge() {
+ const btn = $('sa_btn_chats');
+ if (!btn) {
+ return;
+ }
+ const n = savedChatsCount();
+ btn.textContent = n > 0 ? `История (${n})` : 'История';
+ btn.title = n > 0
+ ? `Сохранённых чатов: ${n}. Переключение восстанавливает параметры.`
+ : 'История чатов (пока пусто)';
+ }
+
+ function formatChatWhen(ts) {
+ if (!ts) {
+ return '';
+ }
+ try {
+ return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ } catch (e) {
+ return '';
+ }
+ }
+
+ function chatMatchesQuery(chat, q) {
+ if (!q) {
+ return true;
+ }
+ const title = String(chat?.title || '').toLowerCase();
+ if (title.includes(q)) {
+ return true;
+ }
+ const msgs = chat?.messages || [];
+ for (const m of msgs) {
+ if (String(m?.content || '').toLowerCase().includes(q)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function renderChatsList() {
+ const root = $('sa_chats_list');
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '';
+ syncHistoryBadge();
+ const q = (state.chatsQuery || '').trim().toLowerCase();
+ let chats = (state.chats || [])
+ .slice()
+ .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
+ .filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
+ if (q) {
+ const local = chats.filter((c) => chatMatchesQuery(c, q));
+ const seen = new Set(local.map((c) => c.id));
+ const extra = (state.chatsSearchHits || []).filter((h) => h && h.id && !seen.has(h.id));
+ chats = local.concat(extra);
+ }
+ if (!chats.length) {
+ root.innerHTML = q
+ ? 'Ничего не нашлось.
'
+ : 'Пока пусто. Напиши что-нибудь в чат — он появится здесь. Переключение вернёт и сообщения, и параметры Generate.
';
+ return;
+ }
+ for (const c of chats) {
+ const row = document.createElement('div');
+ row.className = 'sa-chat-row' + (c.id === state.activeChatId ? ' sa-chat-row-active' : '');
+ row.dataset.id = c.id;
+ const n = (c.messages || []).length || Number(c.messages_count) || 0;
+ const bits = [];
+ if (c.params?.width && c.params?.height) {
+ bits.push(`${c.params.width}×${c.params.height}`);
+ }
+ if (c.params?.steps != null) {
+ bits.push(`steps ${c.params.steps}`);
+ }
+ if (c.params?.cfg != null) {
+ bits.push(`cfg ${c.params.cfg}`);
+ }
+ if (Array.isArray(c.params?.loras) && c.params.loras.length) {
+ bits.push(`LoRA ${c.params.loras.length}`);
+ }
+ const noParams = !c.params ? ' · без снимка params' : '';
+ row.innerHTML = `${escapeHtml(c.title || 'Чат')} ${escapeHtml([formatChatWhen(c.updatedAt), `${n} сообщ.`, bits.join(' · ')].filter(Boolean).join(' · ') + noParams)} × `;
+ root.appendChild(row);
+ }
+ }
+
+ function setChatsPanelOpen(open) {
+ state.chatsPanelOpen = !!open;
+ const panel = $('sa_chats_panel');
+ const btn = $('sa_btn_chats');
+ if (panel) {
+ panel.hidden = !state.chatsPanelOpen;
+ }
+ btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
+ if (state.chatsPanelOpen) {
+ saveActiveChatToStore();
+ const search = $('sa_chats_search');
+ if (search) {
+ search.value = state.chatsQuery || '';
+ search.focus();
+ }
+ renderChatsList();
+ }
+ }
+
+ async function startNewChat({ saveCurrent = true, force = false } = {}) {
+ if (!force && (state.busy || state.generating)) {
+ setStatus('Занято — дождись конца ответа или Стоп');
+ return;
+ }
+ if (force) {
+ // Drop in-flight reply so it cannot land in the new chat.
+ abortInFlightWork({ status: '' });
+ }
+ setChatsPanelOpen(false);
+ if (saveCurrent) {
+ saveActiveChatToStore({ dropEmpty: true });
+ }
+ state.sessionExact = {};
+ state.lastUserParamIntent = false;
+ state.pendingSilentGen = false;
+ state.lastPatch = null;
+ clearGenResults();
+ const chat = {
+ id: chatUid(),
+ title: 'Новый чат',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ messages: [],
+ params: snapshotChatParams(),
+ };
+ state.chats.unshift(chat);
+ state.activeChatId = chat.id;
+ state.history = [];
+ state.critiqueHopUsed = false;
+ state.visionHopUsed = false;
+ state.packUserTouched = false;
+ state.pendingPersonaNote = null;
+ if (state.streamEl) {
+ try { state.streamEl.remove(); } catch (e) { /* ignore */ }
+ state.streamEl = null;
+ }
+ syncBuildGenButton();
+ resetMessagesUi();
+ persistChatsStore();
+ updateSessionLabel();
+ renderBoard();
+ syncHistoryBadge();
+ renderChatsList();
+ setStatus('Новый чат — параметры Generate как сейчас');
+ maybeWelcome();
+ }
+
+ async function switchToChat(id) {
+ if (!id || id === state.activeChatId) {
+ setChatsPanelOpen(false);
+ return;
+ }
+ if (state.busy || state.generating) {
+ setStatus('Занято — нельзя сменить чат сейчас');
+ return;
+ }
+ saveActiveChatToStore({ dropEmpty: true });
+ let chat = findChat(id);
+ if (!chat || !(chat.messages || []).length) {
+ try {
+ const full = await diskPersist()?.getChat?.(id);
+ if (full) {
+ const idx = (state.chats || []).findIndex((c) => c.id === id);
+ if (idx >= 0) {
+ state.chats[idx] = full;
+ } else {
+ state.chats.unshift(full);
+ }
+ chat = full;
+ }
+ } catch (e) {
+ console.warn('Assistent: getChat failed', id, e);
+ }
+ }
+ if (!chat) {
+ setStatus('Чат не найден');
+ return;
+ }
+ state.activeChatId = chat.id;
+ state.history = slimHistoryMessages(chat.messages);
+ state.critiqueHopUsed = false;
+ state.visionHopUsed = false;
+ state.packUserTouched = false;
+ state.pendingPersonaNote = null;
+ state.pendingSilentGen = false;
+ state.lastUserParamIntent = false;
+ if (state.streamEl) {
+ try { state.streamEl.remove(); } catch (e) { /* ignore */ }
+ state.streamEl = null;
+ }
+ renderHistoryIntoUi(state.history);
+ const result = await restoreChatParams(chat.params);
+ updateSessionLabel();
+ syncHistoryBadge();
+ renderChatsList();
+ setChatsPanelOpen(false);
+ setView('chat');
+ if (result?.restored) {
+ setStatus(`Чат «${chat.title}» · параметры восстановлены`);
+ } else {
+ setStatus(`Чат «${chat.title}» · снимок параметров отсутствует — Generate не менялся`);
+ }
+ }
+
+ function deleteChat(id) {
+ if (!id) {
+ return;
+ }
+ const wasActive = id === state.activeChatId;
+ state.chats = state.chats.filter((c) => c.id !== id);
+ if (wasActive) {
+ state.activeChatId = null;
+ }
+ diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
+ persistChatsStore();
+ syncHistoryBadge();
+ if (wasActive) {
+ startNewChat({ saveCurrent: false, force: true });
+ } else {
+ renderChatsList();
+ }
+ }
+
+ async function initChatSessions() {
+ loadChatsStore();
+ await loadChatsFromDisk();
+ // Always open a fresh chat on startup; past chats stay in History.
+ startNewChat({ saveCurrent: false, force: true });
+ syncHistoryBadge();
+ renderChatsList();
+ }
+
+ function persistHistory() {
+ if (state.restoringChat) {
+ return;
+ }
+ if (!state.activeChatId) {
+ const chat = {
+ id: chatUid(),
+ title: 'Новый чат',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ messages: [],
+ params: snapshotChatParams(),
+ };
+ state.chats.unshift(chat);
+ state.activeChatId = chat.id;
+ }
+ const chat = findChat(state.activeChatId);
+ if (!chat) {
+ return;
+ }
+ chat.messages = slimHistoryMessages(state.history);
+ chat.params = snapshotChatParams();
+ chat.updatedAt = Date.now();
+ chat.title = titleFromMessages(chat.messages);
+ persistChatsStore();
+ updateSessionLabel();
+ syncHistoryBadge();
+ }
+
+ function restoreHistory() {
+ // Replaced by initChatSessions — kept as no-op for safety.
+ }
+
+ function clearPersistedHistory() {
+ if (state.activeChatId) {
+ const chat = findChat(state.activeChatId);
+ if (chat) {
+ chat.messages = [];
+ chat.title = 'Новый чат';
+ chat.params = snapshotChatParams();
+ chat.updatedAt = Date.now();
+ }
+ persistChatsStore();
+ }
+ updateSessionLabel();
+ renderChatsList();
+ }
+
+ function clearChatHistory() {
+ abortInFlightWork({ status: '' });
+ state.history = [];
+ state.critiqueHopUsed = false;
+ state.visionHopUsed = false;
+ state.packUserTouched = false;
+ state.pendingPersonaNote = null;
+ state.sessionExact = {};
+ state.lastUserParamIntent = false;
+ state.pendingSilentGen = false;
+ state.lastPatch = null;
+ clearGenResults();
+ syncBuildGenButton();
+ clearPersistedHistory();
+ resetMessagesUi('Чат очищен
Сообщения сброшены. Параметры Generate на месте. + — новый чат в Историю, История — прошлые диалоги.
');
+ setStatus('Чат очищен');
+ updateSessionLabel();
+ syncHistoryBadge();
+ renderBoard();
+ }
+
+ function hideSlashMenu() {
+ const menu = $('sa_slash_menu');
+ if (menu) {
+ menu.hidden = true;
+ menu.innerHTML = '';
+ }
+ state.slashIndex = 0;
+ }
+
+ function slashMatches(text) {
+ const t = String(text || '');
+ if (!t.startsWith('/')) {
+ return [];
+ }
+ const q = t.toLowerCase();
+ return SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q) || q === '/' || c.cmd.toLowerCase().includes(q.slice(1)));
+ }
+
+ function renderSlashMenu(items) {
+ const menu = $('sa_slash_menu');
+ if (!menu) {
+ return;
+ }
+ if (!items.length) {
+ hideSlashMenu();
+ return;
+ }
+ menu.hidden = false;
+ menu.innerHTML = '';
+ state.slashIndex = Math.max(0, Math.min(state.slashIndex, items.length - 1));
+ items.forEach((item, i) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-slash-item' + (i === state.slashIndex ? ' sa-slash-active' : '');
+ btn.setAttribute('role', 'option');
+ btn.innerHTML = `${escapeHtml(item.cmd.trim())} — ${escapeHtml(item.hint)}`;
+ btn.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ applySlashPick(item);
+ });
+ menu.appendChild(btn);
+ });
+ }
+
+ function applySlashPick(item) {
+ const input = $('sa_input');
+ if (!input || !item) {
+ return;
+ }
+ input.value = item.cmd;
+ hideSlashMenu();
+ input.focus();
+ const pos = input.value.length;
+ input.setSelectionRange(pos, pos);
+ }
+
+ function updateSlashMenuFromInput() {
+ const text = $('sa_input')?.value || '';
+ if (!text.startsWith('/') || text.includes('\n') || /\s/.test(text.trim().slice(1)) && !text.endsWith(' ')) {
+ // show while typing command token only
+ const token = text.split(/\s/)[0] || '';
+ if (!token.startsWith('/') || (text.includes(' ') && !SLASH_COMMANDS.some((c) => c.cmd.startsWith(token)))) {
+ if (!(token.startsWith('/') && !text.includes(' '))) {
+ hideSlashMenu();
+ return;
+ }
+ }
+ }
+ const token = (text.split(/\s/)[0] || '');
+ if (!token.startsWith('/') || text.indexOf(' ') > 0) {
+ hideSlashMenu();
+ return;
+ }
+ renderSlashMenu(slashMatches(token));
+ }
+
+ function onPersonaChanged() {
+ const id = $('sa_persona')?.value || 'neutral';
+ state.sessionExact = {};
+ state.lastUserParamIntent = false;
+ saveSettings();
+ loadConfig(id, (data) => {
+ const title = data?.personas?.find((p) => p.id === id)?.title
+ || (state.personas || []).find((p) => p.id === id)?.title
+ || id;
+ if (data?.personas) {
+ state.personas = data.personas;
+ }
+ appendSystemNote(`Тон → ${title}`);
+ state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
+ if (data?.assistant?.default_pack && $('sa_pack') && !state.packUserTouched) {
+ const packId = data.assistant.default_pack;
+ if ([...($('sa_pack').options || [])].some((o) => o.value === packId)) {
+ $('sa_pack').value = packId;
+ }
+ }
+ fillEmptyParamsFromExact();
+ renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
+ syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
+ if (state.view === 'settings') {
+ if (state.settingsTab === 'user') {
+ refreshUserPrefs();
+ }
+ if (state.settingsTab === 'craft') {
+ renderMemoryList();
+ }
+ if (state.settingsTab === 'more') {
+ fillKnobsFromConfig(data);
+ }
+ }
+ });
+ }
+
+ function countPromptImages() {
+ try {
+ const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
+ if (!box) {
+ return 0;
+ }
+ const text = box.value || '';
+ const matches = text.match(/)/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(/]*>[\s\S]*?<\/image>/gi, '[image omitted]');
+ s = s.replace(/ ]*>/gi, '[image omitted]');
+ if (s.length > CONTEXT_PROMPT_MAX) {
+ s = s.slice(0, CONTEXT_PROMPT_MAX) + '…';
+ }
+ return s;
+ }
+
+ function collectLiveContext() {
+ const inv = state.inventory || {};
+ const initCtx = readInitContext();
+ const ctx = {
+ architecture_ok: isKreaSelected(),
+ checkpoint: null,
+ prompt: slimPromptForContext(val('alt_prompt_textbox') || val('input_prompt') || ''),
+ negative: slimPromptForContext(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || ''),
+ width: parseInt(val('input_width') || '0', 10) || null,
+ height: parseInt(val('input_height') || '0', 10) || null,
+ steps: parseInt(val('input_steps') || '0', 10) || null,
+ cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
+ sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
+ seed: val('input_seed') || null,
+ sampler: val('input_sampler') || val('input_samplerate') || null,
+ scheduler: val('input_scheduler') || null,
+ batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
+ prompt_image_count: countPromptImages(),
+ selected_loras: [],
+ available_loras: [],
+ available_checkpoints: slimInventoryCheckpoints(inv.checkpoints || [], 8),
+ wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 20),
+ inventory_at: inv.inventory_at || null,
+ has_vision_image: visionReadySlots().length > 0,
+ image_slots: slotCatalog(),
+ attached_slot_ids: attachableSlots().map((s) => s.id),
+ gen_results: (state.genResults || []).map((r) => ({
+ id: r.id,
+ label: r.label,
+ has_image: !!r.src,
+ selected: r.id === state.selectedGenResultId,
+ })),
+ selected_gen_result: state.selectedGenResultId || null,
+ has_civitai_key: !!inv.has_civitai_key,
+ auto_apply: !!$('sa_auto_apply')?.checked,
+ auto_generate: !!$('sa_auto_generate')?.checked,
+ persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral',
+ model_cards: [],
+ user_prefs_count: 0,
+ ...initCtx,
+ };
+ {
+ const slim = slimInventoryLoras(inv.loras || [], INVENTORY_PROMPT_NAMES);
+ ctx.available_loras = slim;
+ if ((inv.loras || []).length > slim.length) {
+ ctx.available_loras_truncated = true;
+ ctx.available_loras_total = (inv.loras || []).length;
+ }
+ }
+
+ try {
+ const model = resolveCurrentCheckpoint();
+ if (model.name || model.architecture) {
+ ctx.checkpoint = {
+ name: model.name || model.title || null,
+ architecture: model.architecture || model.compat_class || model.class || null,
+ title: model.title || null,
+ };
+ }
+ } catch (e) { /* ignore */ }
+
+ try {
+ if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
+ const byName = new Map();
+ for (const l of inv.loras || []) {
+ if (l?.name) {
+ byName.set(String(l.name).toLowerCase(), l);
+ }
+ }
+ ctx.selected_loras = loraHelper.selected.map((l) => {
+ const name = l.name || l;
+ const invRow = byName.get(String(name).toLowerCase()) || {};
+ const out = {
+ name,
+ weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[name]) || invRow.default_weight || 1,
+ };
+ if (invRow.trigger_phrase) {
+ out.trigger_phrase = invRow.trigger_phrase;
+ }
+ if (Array.isArray(invRow.triggers) && invRow.triggers.length) {
+ out.triggers = invRow.triggers.slice(0, 8);
+ }
+ if (invRow.blurb) {
+ out.blurb = invRow.blurb;
+ }
+ return out;
+ });
+ // selected_loras = enabled; do not also emit enabled_loras (duplicate).
+ }
+ } catch (e) { /* ignore */ }
+
+ // Recommendation cards: checkpoint + selected LoRAs only when they add beyond inventory.
+ const cardKeys = [];
+ const seenCard = new Set();
+ const addKey = (kind, name) => {
+ if (!kind || !name) {
+ return;
+ }
+ const key = `${kind}:${name}`;
+ if (seenCard.has(key)) {
+ return;
+ }
+ seenCard.add(key);
+ cardKeys.push({ kind, name });
+ };
+ if (ctx.checkpoint?.name) {
+ addKey('checkpoint', ctx.checkpoint.name);
+ }
+ for (const l of ctx.selected_loras || []) {
+ if (l?.name) {
+ addKey('lora', l.name);
+ }
+ }
+ for (const k of cardKeys) {
+ const cached = state.modelCards[`${k.kind}:${k.name}`];
+ if (!cached) {
+ continue;
+ }
+ const slim = slimCardForContext(cached);
+ if (!slim) {
+ continue;
+ }
+ if (k.kind === 'lora') {
+ const sel = (ctx.selected_loras || []).find(
+ (l) => String(l.name || '').toLowerCase() === String(k.name).toLowerCase(),
+ );
+ const inventoryRich = !!(sel && (sel.triggers?.length || sel.trigger_phrase || sel.blurb));
+ const cardExtra = !!(slim.when || slim.avoid || slim.prompt_hint || slim.notes);
+ if (inventoryRich && !cardExtra) {
+ continue;
+ }
+ }
+ ctx.model_cards.push(slim);
+ }
+
+ // Fallback if inventory empty
+ if (!ctx.available_loras.length) {
+ try {
+ const models = (typeof allModels !== 'undefined' && allModels) || (typeof model_list !== 'undefined' && model_list) || [];
+ const list = Array.isArray(models) ? models : Object.values(models || {});
+ for (const m of list) {
+ if (!m) {
+ continue;
+ }
+ const folder = `${m.folder || m.path || ''}`;
+ const isLora = /lora/i.test(m.category || m.type || '') || (m.name && String(m.name).toLowerCase().includes('lora'));
+ const inLoraFolder = /lora/i.test(folder);
+ if (!(isLora || inLoraFolder)) {
+ continue;
+ }
+ ctx.available_loras.push({
+ name: m.name || m.title,
+ title: m.title || m.name,
+ trigger_phrase: m.trigger_phrase || m.trigger || (m.metadata && (m.metadata.trigger_phrase || m.metadata.trigger)) || null,
+ architecture: m.architecture || null,
+ });
+ }
+ if (ctx.available_loras.length > INVENTORY_PROMPT_NAMES) {
+ ctx.available_loras = ctx.available_loras.slice(0, INVENTORY_PROMPT_NAMES);
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ try {
+ const model = ctx.checkpoint || {};
+ const blob = `${model.name || ''} ${model.title || ''}`.toLowerCase();
+ const hasRaw = /\braw\b/.test(blob);
+ const hasTurbo = /\bturbo\b/.test(blob);
+ const profile = hasRaw && !hasTurbo ? 'raw' : 'turbo';
+ ctx.krea_profile = profile;
+ const defaults = mergedGenerationDefaults(profile);
+ // Only send recommended_params when live UI differs from Exact-backed defaults
+ // (Exact itself is already in the system prompt).
+ const rec = {
+ steps: defaults.steps ?? 8,
+ cfg: defaults.cfg ?? 1,
+ sigma_shift: defaults.sigma_shift ?? 1.15,
+ };
+ if (defaults.aspect) {
+ rec.aspect = defaults.aspect;
+ }
+ const liveDiffers = (ctx.steps != null && ctx.steps !== rec.steps)
+ || (ctx.cfg != null && ctx.cfg !== rec.cfg)
+ || (ctx.sigma_shift != null && ctx.sigma_shift !== rec.sigma_shift);
+ if (liveDiffers) {
+ ctx.recommended_params = rec;
+ }
+ } catch (e) {
+ ctx.krea_profile = 'turbo';
+ }
+
+ ctx.session_exact = state.sessionExact && Object.keys(state.sessionExact).length
+ ? { ...state.sessionExact }
+ : undefined;
+ // Exact KV is already in the system prompt — do not duplicate the full blob into live context.
+ if (!ctx.session_exact) {
+ delete ctx.session_exact;
+ }
+
+ return ctx;
+ }
+
+ function slimCardForContext(card) {
+ if (!card || typeof card !== 'object') {
+ return null;
+ }
+ const out = {
+ kind: card.kind || null,
+ name: card.name || null,
+ triggers: Array.isArray(card.triggers) ? card.triggers.slice(0, 8) : undefined,
+ weight: card.weight != null ? card.weight : undefined,
+ when: card.when ? String(card.when).slice(0, 160) : undefined,
+ avoid: card.avoid ? String(card.avoid).slice(0, 120) : undefined,
+ prompt_hint: card.prompt_hint ? String(card.prompt_hint).slice(0, 160) : undefined,
+ notes: card.notes ? String(card.notes).slice(0, 200) : undefined,
+ };
+ const clean = {};
+ for (const [k, v] of Object.entries(out)) {
+ if (v != null && v !== '') {
+ clean[k] = v;
+ }
+ }
+ return clean;
+ }
+
+ function summarizeTaste() {
+ const t = state.taste || {};
+ if (!(t.styles?.length || t.likes?.length || t.avoid?.length || t.notes)) {
+ return null;
+ }
+ return {
+ styles: (t.styles || []).slice(0, 8),
+ likes: (t.likes || []).slice(0, 10),
+ avoid: (t.avoid || []).slice(0, 8),
+ notes: t.notes ? String(t.notes).slice(0, 240) : undefined,
+ };
+ }
+
+ function slimInventoryLoras(list, limit) {
+ const selected = new Set();
+ try {
+ if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
+ for (const l of loraHelper.selected) {
+ selected.add(String(l.name || l || '').toLowerCase());
+ }
+ }
+ } catch (e) { /* ignore */ }
+ const namesCap = Math.min(limit || INVENTORY_PROMPT_NAMES, INVENTORY_PROMPT_NAMES);
+ const richCap = Math.max(4, Math.min(INVENTORY_PROMPT_RICH, namesCap));
+ const rows = (list || []).map((l) => {
+ const sel = selected.has(String(l.name || '').toLowerCase());
+ const hasCard = !!l.has_card;
+ const krea = !!l.krea_likely;
+ const blurb = l.blurb || l.usage_hint || null;
+ return {
+ name: l.name,
+ title: l.title || l.name,
+ trigger_phrase: l.trigger_phrase || null,
+ triggers: Array.isArray(l.triggers) ? l.triggers.slice(0, 8) : undefined,
+ architecture: l.architecture || null,
+ compat_class: l.compat_class || null,
+ has_card: hasCard,
+ krea_likely: krea,
+ blurb,
+ default_weight: l.default_weight || undefined,
+ tags: Array.isArray(l.tags) ? l.tags.slice(0, 6) : undefined,
+ _score: (sel ? 1000 : 0) + (hasCard ? 200 : 0) + (krea ? 50 : 0) + (blurb ? 10 : 0),
+ };
+ });
+ rows.sort((a, b) => b._score - a._score || String(a.name).localeCompare(String(b.name)));
+ // Rich: enabled + top krea/card (triggers/blurb). Rest: name (+ krea_likely) only.
+ let richUsed = 0;
+ const out = [];
+ for (const row of rows) {
+ if (out.length >= namesCap) {
+ break;
+ }
+ const sel = selected.has(String(row.name || '').toLowerCase());
+ let wantRich = sel;
+ if (!wantRich && richUsed < richCap && (row.krea_likely || row.has_card || row.blurb)) {
+ wantRich = true;
+ }
+ if (wantRich) {
+ const rich = { name: row.name, title: row.title };
+ if (row.trigger_phrase) {
+ rich.trigger_phrase = row.trigger_phrase;
+ }
+ if (row.triggers) {
+ rich.triggers = row.triggers;
+ }
+ if (row.krea_likely) {
+ rich.krea_likely = true;
+ }
+ if (row.has_card) {
+ rich.has_card = true;
+ }
+ if (row.blurb) {
+ rich.blurb = row.blurb;
+ }
+ if (row.default_weight) {
+ rich.default_weight = row.default_weight;
+ }
+ if (row.architecture) {
+ rich.architecture = row.architecture;
+ }
+ out.push(rich);
+ if (!sel) {
+ richUsed++;
+ }
+ } else {
+ const nameOnly = { name: row.name };
+ if (row.krea_likely) {
+ nameOnly.krea_likely = true;
+ }
+ out.push(nameOnly);
+ }
+ }
+ return out;
+ }
+
+ function slimInventoryCheckpoints(list, limit) {
+ const rows = (list || []).slice();
+ rows.sort((a, b) => ((b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0)) || ((b.has_card ? 1 : 0) - (a.has_card ? 1 : 0)) || String(a.name).localeCompare(String(b.name)));
+ return rows.slice(0, limit || 8).map((c) => {
+ const out = {
+ name: c.name,
+ title: c.title || c.name,
+ };
+ if (c.architecture) {
+ out.architecture = c.architecture;
+ }
+ if (c.krea_likely) {
+ out.krea_likely = true;
+ }
+ if (c.has_card) {
+ out.has_card = true;
+ }
+ return out;
+ });
+ }
+
+ function loadTaste() {
+ try {
+ const raw = localStorage.getItem(LS_TASTE);
+ if (!raw) {
+ return;
+ }
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed === 'object') {
+ state.taste = {
+ styles: Array.isArray(parsed.styles) ? parsed.styles.slice(0, 12) : [],
+ likes: Array.isArray(parsed.likes) ? parsed.likes.slice(0, 16) : [],
+ avoid: Array.isArray(parsed.avoid) ? parsed.avoid.slice(0, 12) : [],
+ notes: String(parsed.notes || '').slice(0, 400),
+ updated: parsed.updated || 0,
+ };
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ function saveTaste() {
+ try {
+ localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
+ } catch (e) { /* ignore */ }
+ saveTasteToServerDebounced();
+ }
+
+ function pushUnique(arr, value, max) {
+ const v = String(value || '').trim();
+ if (!v || v.length < 2) {
+ return;
+ }
+ const lower = v.toLowerCase();
+ const next = (arr || []).filter((x) => String(x).toLowerCase() !== lower);
+ next.unshift(v.slice(0, 80));
+ return next.slice(0, max);
+ }
+
+ function updateTasteFromPatch(patch, userText) {
+ if (!patch) {
+ return;
+ }
+ const taste = state.taste || { styles: [], likes: [], avoid: [], notes: '' };
+ if (Array.isArray(patch.loras)) {
+ for (const l of patch.loras) {
+ const name = l?.name || l;
+ if (name) {
+ taste.likes = pushUnique(taste.likes, name, 16);
+ }
+ }
+ }
+ const aspect = patch.aspect || null;
+ if (aspect) {
+ taste.styles = pushUnique(taste.styles, `aspect ${aspect}`, 12);
+ }
+ if (patch.creativity) {
+ taste.styles = pushUnique(taste.styles, `creativity:${patch.creativity}`, 12);
+ }
+ const ut = String(userText || '').toLowerCase();
+ if (/фото|photo|photoreal|реализм|film grain/.test(ut)) {
+ taste.styles = pushUnique(taste.styles, 'photoreal / film', 12);
+ }
+ if (/аниме|anime|illustration|иллюстр/.test(ut)) {
+ taste.styles = pushUnique(taste.styles, 'illustration / anime', 12);
+ }
+ if (/без\s+3d|не\s+3d|no\s+3d|не\s+render/.test(ut)) {
+ taste.avoid = pushUnique(taste.avoid, '3D render look', 12);
+ }
+ taste.updated = Date.now();
+ state.taste = taste;
+ saveTaste();
+ syncTasteHintsToUserPrefs(taste);
+ }
+
+ function syncTasteHintsToUserPrefs(taste) {
+ if (typeof genericRequest !== 'function' || !taste) {
+ return;
+ }
+ const upsert = (key, text) => {
+ if (!text) {
+ return;
+ }
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ { key, text: String(text).slice(0, 240), scope: 'global', source: 'migrated_taste', pinned: false },
+ () => {},
+ 0,
+ () => {},
+ );
+ };
+ if (taste.avoid?.[0]) {
+ upsert('avoid_hint', `Avoid: ${taste.avoid.slice(0, 4).join('; ')}`);
+ }
+ if (taste.styles?.[0]) {
+ upsert('style_hint', `Styles: ${taste.styles.slice(0, 4).join('; ')}`);
+ }
+ if (taste.likes?.[0]) {
+ upsert('like_hint', `Often uses: ${taste.likes.slice(0, 4).join('; ')}`);
+ }
+ }
+
+ // Fallback key list — only used if assistent.patch.js failed to load.
+ const FALLBACK_PATCH_KEYS = [
+ 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
+ 'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise',
+ 'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack',
+ 'memories', 'user_prefs', 'variants',
+ ];
+
+ function isPatchObject(obj) {
+ if (window.SA && typeof SA.isPatchObject === 'function') {
+ return SA.isPatchObject(obj);
+ }
+ if (!obj || typeof obj !== 'object') {
+ return false;
+ }
+ if (isCardObject(obj)) {
+ return false;
+ }
+ return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
+ }
+
+ function isCardObject(obj) {
+ if (window.SA && typeof SA.isCardObject === 'function') {
+ return SA.isCardObject(obj);
+ }
+ if (!obj || typeof obj !== 'object') {
+ return false;
+ }
+ // Prefer card shape over gen patch when both could match.
+ const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
+ const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height
+ || obj.steps || obj.cfg || obj.aspect || obj.seed != null
+ || obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
+ if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
+ return true;
+ }
+ return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
+ }
+
+ function extractCardJson(text) {
+ if (!text) {
+ return null;
+ }
+ const re = /```(?:json)?\s*([\s\S]*?)```/gi;
+ let match;
+ let last = null;
+ while ((match = re.exec(text)) !== null) {
+ try {
+ const obj = JSON.parse(match[1].trim());
+ if (isCardObject(obj)) {
+ last = obj;
+ }
+ } catch (e) { /* ignore */ }
+ }
+ if (last) {
+ return last;
+ }
+ try {
+ const obj = JSON.parse(text.trim());
+ return isCardObject(obj) ? obj : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function extractPatch(text) {
+ if (window.SA && typeof SA.extractPatch === 'function') {
+ return SA.extractPatch(text);
+ }
+ if (!text) {
+ return { prose: text || '', patch: null };
+ }
+ const re = /```(?:json)?\s*([\s\S]*?)```/gi;
+ let match;
+ let lastPatch = null;
+ let prose = text;
+ while ((match = re.exec(text)) !== null) {
+ try {
+ const obj = JSON.parse(match[1].trim());
+ if (isPatchObject(obj)) {
+ lastPatch = obj;
+ prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
+ }
+ } catch (e) { /* not json */ }
+ }
+ return { prose, patch: lastPatch };
+ }
+
+ function normalizeAspect(raw) {
+ if (raw == null) {
+ return null;
+ }
+ let s = String(raw).trim().toLowerCase().replace(/\s+/g, '');
+ if (!s) {
+ return null;
+ }
+ if (s === 'square') {
+ s = '1:1';
+ } else if (s === 'portrait' || s === 'vert') {
+ s = '2:3';
+ } else if (s === 'landscape' || s === 'horiz') {
+ s = '16:9';
+ } else if (s === 'cinematic' || s === 'ultrawide') {
+ s = '2.35:1';
+ }
+ return ASPECT_TABLE[s] ? s : null;
+ }
+
+ function sizeFromAspect(aspect) {
+ const key = normalizeAspect(aspect);
+ return key ? ASPECT_TABLE[key] : null;
+ }
+
+ function guessAspectFromSize(w, h) {
+ const width = parseInt(w, 10);
+ const height = parseInt(h, 10);
+ if (!width || !height) {
+ return null;
+ }
+ let best = null;
+ let bestDist = Infinity;
+ for (const [key, [aw, ah]] of Object.entries(ASPECT_TABLE)) {
+ const dist = Math.abs(width / height - aw / ah) + Math.abs(width - aw) / 4000 + Math.abs(height - ah) / 4000;
+ if (dist < bestDist) {
+ bestDist = dist;
+ best = key;
+ }
+ }
+ return bestDist < 0.12 ? best : null;
+ }
+
+ function clearPromptImagesInBox() {
+ const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
+ if (!box) {
+ return false;
+ }
+ const before = box.value || '';
+ const next = before
+ .replace(/]*>[\s\S]*?<\/image>/gi, '')
+ .replace(/]*\/?>/gi, '')
+ .replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+ if (next === before.trim()) {
+ return false;
+ }
+ box.value = next;
+ box.dispatchEvent(new Event('input', { bubbles: true }));
+ box.dispatchEvent(new Event('change', { bubbles: true }));
+ return true;
+ }
+
+ function clearPatchBlocksOnly() {
+ document.querySelectorAll('#sa_messages .sa-patch, #sa_messages .sa-patch-stale, #sa_messages .sa-civitai-list').forEach((el) => el.remove());
+ state.lastPatch = null;
+ syncBuildGenButton();
+ setStatus('Патчи убраны из чата');
+ }
+
+ function toggleMoreMenu(menuId, btnId) {
+ const menu = $(menuId);
+ const btn = $(btnId);
+ if (!menu) {
+ return;
+ }
+ const open = menu.hidden;
+ document.querySelectorAll('.sa-more-menu').forEach((m) => {
+ m.hidden = true;
+ });
+ document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
+ if (open) {
+ menu.hidden = false;
+ btn?.setAttribute('aria-expanded', 'true');
+ }
+ }
+
+ function closeAllMoreMenus() {
+ document.querySelectorAll('.sa-more-menu').forEach((m) => {
+ m.hidden = true;
+ });
+ document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
+ }
+
+ function setPackValue(packName, { flash, user } = {}) {
+ const pack = $('sa_pack');
+ if (!pack || !packName) {
+ return false;
+ }
+ const resolved = PACK_ALIASES[String(packName).trim()] || String(packName).trim();
+ if (![...pack.options].some((o) => o.value === resolved)) {
+ return false;
+ }
+ if (pack.value !== resolved) {
+ pack.value = resolved;
+ saveSettings();
+ }
+ if (user) {
+ state.packUserTouched = true;
+ }
+ if (flash) {
+ pack.classList.add('sa-pack-flash');
+ setTimeout(() => pack.classList.remove('sa-pack-flash'), 900);
+ }
+ syncModeBadge();
+ return true;
+ }
+
+ function autoSelectPack(text) {
+ if (state.packUserTouched) {
+ return null;
+ }
+ // Комбайн «Обычный» сам выбирает поведение — не переключаем pack.
+ const cur = $('sa_pack')?.value || defaultPackId();
+ if (cur === 'ordinary') {
+ return null;
+ }
+ const t = String(text || '').toLowerCase();
+ if (!t.trim()) {
+ return null;
+ }
+ // Param / aspect asks must leave a stuck critique_image pack from auto-critique.
+ if (userTextMentionsParams(t) || parseAspectFromUserText(t)) {
+ return cur === 'critique_image' || cur === 'describe_ref' ? 'ordinary' : 'form_params';
+ }
+ if (cyrTokenRe('поправь|исправь|перепиши|улучши').test(t)
+ || /\b(fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
+ return 'write_prompt';
+ }
+ if (cyrTokenRe('опиши\\s+(реф|изображ[а-яё]*|этот|эту|картинк[а-яё]*|референс)').test(t)
+ || /\b(prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t)
+ || /опиши\s+(этот|эту|картинк|референс)/i.test(t)) {
+ return 'describe_ref';
+ }
+ // Bare «посмотри/смотри» is casual chat — only critique when aimed at a result/frame.
+ if (/\b(critique|criticize)\b/i.test(t)
+ || cyrTokenRe('критик[а-яё]*|что\\s+не\\s+так|разбери').test(t)
+ || /(?:^|[^а-яёa-z0-9_])(посмотри|смотри)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген)/i.test(t)) {
+ return 'critique_image';
+ }
+ if (/\b(inpaint|mask|img2img)\b/i.test(t)
+ || cyrTokenRe('замажь|закрась|руки|лицо|маск[а-яё]*').test(t)
+ || /init\s*image/i.test(t)) {
+ return 'inpaint_edit';
+ }
+ // Any non-critique follow-up after auto-critique should leave critique mode.
+ if (cur === 'critique_image') {
+ return 'ordinary';
+ }
+ if (/\b(moodboard|compose|scene)\b/i.test(t)
+ || cyrTokenRe('сцен[а-яё]*|атмосфер[а-яё]*|мизансцен[а-яё]*').test(t)) {
+ return 'compose_scene';
+ }
+ return 'write_prompt';
+ }
+
+ function restoreDefaultPackAfterHop() {
+ if (state.packUserTouched) {
+ return;
+ }
+ const cur = $('sa_pack')?.value || '';
+ if (cur === 'critique_image' || cur === 'describe_ref') {
+ setPackValue(defaultPackId(), { flash: true });
+ }
+ }
+
+ function patchHasGenTrigger(patch) {
+ if (!patch) {
+ return false;
+ }
+ if (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')) {
+ return true;
+ }
+ return (
+ patch.prompt != null ||
+ patch.loras ||
+ patch.width != null ||
+ patch.height != null ||
+ patch.aspect != null ||
+ patch.steps != null ||
+ patch.cfg != null ||
+ patch.seed != null ||
+ patch.sigma_shift != null ||
+ patch.images != null ||
+ patch.batch != null ||
+ patch.vary === true ||
+ (Array.isArray(patch.variants) && patch.variants.length > 0) ||
+ patch.use_init_image ||
+ patch.clear_init_image ||
+ patch.init_creativity != null ||
+ patch.denoise != null ||
+ patch.use_mask_image ||
+ patch.clear_mask_image ||
+ patch.clear_prompt_images
+ );
+ }
+
+ async function applyPatch(patch, which) {
+ if (!patch) {
+ return;
+ }
+ const doPrompt = !which || which === 'all' || which === 'prompt';
+ const doLoras = !which || which === 'all' || which === 'loras';
+ const doParams = !which || which === 'all' || which === 'size' || which === 'params';
+ const doInit = !which || which === 'all' || which === 'params' || which === 'init';
+
+ if (patch.pack) {
+ setPackValue(patch.pack, { flash: true });
+ }
+
+ if (doPrompt && patch.clear_prompt_images) {
+ clearPromptImagesInBox();
+ }
+
+ if (doPrompt && patch.prompt != null) {
+ const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
+ if (box) {
+ box.value = patch.prompt;
+ box.dispatchEvent(new Event('input', { bubbles: true }));
+ box.dispatchEvent(new Event('change', { bubbles: true }));
+ }
+ if (patch.negative != null) {
+ setVal('input_negativeprompt', patch.negative);
+ }
+ if (Array.isArray(patch.loras)) {
+ for (const l of patch.loras) {
+ const triggers = l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []);
+ for (const t of triggers) {
+ if (t && box && box.value && !box.value.includes(t)) {
+ box.value = `${box.value.trim()}, ${t}`;
+ box.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }
+ }
+ }
+ }
+
+ if (doLoras && Array.isArray(patch.loras) && typeof loraHelper !== 'undefined' && loraHelper) {
+ try {
+ if (typeof loraHelper.clearLoras === 'function') {
+ loraHelper.clearLoras();
+ }
+ } catch (e) { /* ignore */ }
+ for (const l of patch.loras) {
+ const name = l.name;
+ if (!name) {
+ continue;
+ }
+ try {
+ if (typeof loraHelper.selectLora === 'function') {
+ loraHelper.selectLora(name);
+ }
+ if (loraHelper.loraWeightPref && l.weight != null) {
+ loraHelper.loraWeightPref[name] = l.weight;
+ }
+ } catch (e) {
+ console.warn('Assistent: selectLora failed', name, e);
+ }
+ }
+ try {
+ if (typeof loraHelper.rebuildUI === 'function') {
+ loraHelper.rebuildUI();
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ if (doParams) {
+ const defaults = mergedGenerationDefaults();
+ const capture = state.lastUserParamIntent;
+ const aspectSize = sizeFromAspect(patch.aspect);
+ if (patch.aspect != null && !shouldSkipSessionRollback('aspect', patch.aspect)) {
+ if (aspectSize) {
+ setVal('input_width', String(aspectSize[0]));
+ setVal('input_height', String(aspectSize[1]));
+ }
+ if (capture) {
+ rememberSessionExact({ aspect: patch.aspect });
+ }
+ } else if (patch.aspect == null && isEmptyParamField(val('input_width'), { treatZeroEmpty: true })
+ && isEmptyParamField(val('input_height'), { treatZeroEmpty: true }) && defaults.aspect) {
+ const fill = sizeFromAspect(defaults.aspect);
+ if (fill) {
+ setVal('input_width', String(fill[0]));
+ setVal('input_height', String(fill[1]));
+ }
+ } else {
+ if (patch.width != null && !shouldSkipSessionRollback('width', patch.width)) {
+ setVal('input_width', String(patch.width));
+ if (capture) {
+ rememberSessionExact({ width: patch.width });
+ }
+ } else if (patch.width == null && isEmptyParamField(val('input_width'), { treatZeroEmpty: true }) && defaults.width != null) {
+ setVal('input_width', String(defaults.width));
+ }
+ if (patch.height != null && !shouldSkipSessionRollback('height', patch.height)) {
+ setVal('input_height', String(patch.height));
+ if (capture) {
+ rememberSessionExact({ height: patch.height });
+ }
+ } else if (patch.height == null && isEmptyParamField(val('input_height'), { treatZeroEmpty: true }) && defaults.height != null) {
+ setVal('input_height', String(defaults.height));
+ }
+ }
+ if (patch.steps != null && !shouldSkipSessionRollback('steps', patch.steps)) {
+ setVal('input_steps', String(patch.steps));
+ if (capture) {
+ rememberSessionExact({ steps: patch.steps });
+ }
+ } else if (patch.steps == null && isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) {
+ setVal('input_steps', String(defaults.steps));
+ }
+ if (patch.cfg != null && !shouldSkipSessionRollback('cfg', patch.cfg)) {
+ if (document.getElementById('input_cfgscale')) {
+ setVal('input_cfgscale', String(patch.cfg));
+ } else {
+ setVal('input_cfg', String(patch.cfg));
+ }
+ if (capture) {
+ rememberSessionExact({ cfg: patch.cfg });
+ }
+ } else if (patch.cfg == null) {
+ const cfgRaw = val('input_cfgscale') || val('input_cfg');
+ if (isEmptyParamField(cfgRaw, { treatZeroEmpty: true }) && defaults.cfg != null) {
+ if (document.getElementById('input_cfgscale')) {
+ setVal('input_cfgscale', String(defaults.cfg));
+ } else if (document.getElementById('input_cfg')) {
+ setVal('input_cfg', String(defaults.cfg));
+ }
+ }
+ }
+ if (patch.vary === true) {
+ setVal('input_seed', '-1');
+ } else if (patch.lock_seed === true) {
+ const cur = val('input_seed');
+ if (cur && String(cur) !== '-1') {
+ setVal('input_seed', cur);
+ }
+ } else if (patch.seed != null && !shouldSkipSessionRollback('seed', patch.seed)) {
+ setVal('input_seed', String(patch.seed));
+ if (capture) {
+ rememberSessionExact({ seed: patch.seed });
+ }
+ }
+ if (patch.sigma_shift != null && !shouldSkipSessionRollback('sigma_shift', patch.sigma_shift)) {
+ setVal('input_sigmashift', String(patch.sigma_shift));
+ if (capture) {
+ rememberSessionExact({ sigma_shift: patch.sigma_shift });
+ }
+ } else if (patch.sigma_shift == null && isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
+ setVal('input_sigmashift', String(defaults.sigma_shift));
+ }
+ if (patch.sampler != null) {
+ if (document.getElementById('input_sampler')) {
+ setVal('input_sampler', String(patch.sampler));
+ }
+ if (capture) {
+ rememberSessionExact({ sampler: patch.sampler });
+ }
+ }
+ if (patch.scheduler != null && document.getElementById('input_scheduler')) {
+ setVal('input_scheduler', String(patch.scheduler));
+ if (capture) {
+ rememberSessionExact({ scheduler: patch.scheduler });
+ }
+ }
+ const batch = patch.images != null ? patch.images : patch.batch;
+ if (batch != null && !shouldSkipSessionRollback('images', batch)) {
+ if (document.getElementById('input_images')) {
+ setVal('input_images', String(batch));
+ } else if (document.getElementById('input_batchsize')) {
+ setVal('input_batchsize', String(batch));
+ }
+ if (capture) {
+ rememberSessionExact({ images: batch });
+ }
+ } else if (batch == null) {
+ const batchId = document.getElementById('input_images') ? 'input_images' : (document.getElementById('input_batchsize') ? 'input_batchsize' : null);
+ const defBatch = defaults.images != null ? defaults.images : defaults.batch;
+ if (batchId && isEmptyParamField(val(batchId), { treatZeroEmpty: true }) && defBatch != null) {
+ setVal(batchId, String(defBatch));
+ }
+ }
+ }
+
+ if (doInit) {
+ const creativity = patch.init_creativity != null ? patch.init_creativity : patch.denoise;
+ if (creativity != null && document.getElementById('input_initimagecreativity')) {
+ setVal('input_initimagecreativity', String(creativity));
+ openInitImageGroup();
+ }
+ if (patch.mask_blur != null && document.getElementById('input_maskblur')) {
+ setVal('input_maskblur', String(patch.mask_blur));
+ }
+ if (patch.mask_grow != null) {
+ if (document.getElementById('input_maskgrow')) {
+ setVal('input_maskgrow', String(patch.mask_grow));
+ } else if (document.getElementById('input_maskshrinkgrow')) {
+ setVal('input_maskshrinkgrow', String(patch.mask_grow));
+ }
+ }
+ if (patch.clear_init_image || patch.clear_mask_image) {
+ if (patch.clear_init_image) {
+ clearFileParam('input_initimage');
+ }
+ if (patch.clear_mask_image) {
+ clearFileParam('input_maskimage');
+ }
+ if (patch.clear_init_image && patch.clear_mask_image) {
+ const toggler = document.getElementById('input_group_content_initimage_toggle');
+ if (toggler) {
+ toggler.checked = false;
+ triggerChangeForEl(toggler);
+ }
+ }
+ }
+ if (patch.select_slot) {
+ const id = normalizeSlotId(patch.select_slot);
+ if (slotById(id)) {
+ state.selectedSlotId = id;
+ renderBoard();
+ }
+ }
+ if (patch.snapshot_generate) {
+ snapshotGenerateToRef();
+ }
+ const initId = patch.slot_to_init || (patch.use_init_image || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_init')) ? state.selectedSlotId : null);
+ const maskId = patch.slot_to_mask || null;
+ const src = resolveSlotSrc(patch.slot_to_init) || selectedSrc() || findCurrentGenerateSrc();
+ const wantInit = patch.use_init_image === true
+ || !!patch.slot_to_init
+ || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_init'));
+ const wantMask = patch.use_mask_image === true
+ || !!patch.slot_to_mask
+ || (Array.isArray(patch.actions) && patch.actions.map(String).includes('use_mask'));
+ if (wantInit) {
+ const initSrc = resolveSlotSrc(initId) || src;
+ if (initSrc) {
+ await setInitFromSrc(initSrc);
+ } else {
+ setStatus('No image for Init — drop a ref or wait for Generate');
+ }
+ }
+ if (wantMask) {
+ const maskSrc = resolveSlotSrc(maskId) || src;
+ if (maskSrc) {
+ await setMaskFromSrc(maskSrc);
+ } else {
+ setStatus('No image for Mask — drop a mask (white=edit) first');
+ }
+ }
+ if (patch.slot_to_prompt_image) {
+ setStatus('Prompt Images: drop the ref into the Swarm prompt box (no auto helper yet)');
+ }
+ }
+
+ // Persona Exact controls (model or user patch). Ignore default-echo inside Generate patches.
+ if (patch.controls && typeof patch.controls === 'object' && !Array.isArray(patch.controls)) {
+ const schema = state.config?.controls || {};
+ const filtered = filterControlPatch(patch.controls, patch);
+ if (Object.keys(filtered).length) {
+ const next = { ...(state.config?.control_values || state.exact?.controls || {}), ...filtered };
+ savePersonaControls(filtered);
+ renderPersonaControls(schema, next);
+ }
+ }
+
+ const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
+ const wantSwitch = acts.includes('persona_switch')
+ || (patch.persona && typeof patch.persona === 'string')
+ || patch._persona_cloned
+ || patch._persona_written;
+ if (wantSwitch) {
+ const newId = String(patch.persona || patch._persona_cloned || patch._persona_written || '').trim();
+ if (newId && AssistentConfigSafeIdClient(newId)) {
+ await refreshPersonasAndSwitch(newId);
+ } else if (acts.includes('persona_clone') || acts.includes('persona_write') || patch.persona_clone) {
+ await refreshPersonasAndSwitch(null);
+ }
+ }
+
+ syncChipHighlight();
+ syncLiveParamsBar();
+ syncBuildGenButton();
+ if (state.activeChatId && !state.restoringChat) {
+ const chat = findChat(state.activeChatId);
+ if (chat) {
+ chat.params = snapshotChatParams();
+ chat.updatedAt = Date.now();
+ persistChatsStore();
+ }
+ }
+ if (!state.restoringChat) {
+ setStatus(patch._persona_error ? `Persona: ${patch._persona_error}` : 'Applied patch');
+ }
+ }
+
+ function AssistentConfigSafeIdClient(id) {
+ return /^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/.test(String(id || ''));
+ }
+
+ async function refreshPersonasAndSwitch(preferId) {
+ await new Promise((resolve) => {
+ genericRequest(
+ 'AssistentListPersonas',
+ {},
+ async (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas.map((p) => ({
+ id: p.id,
+ title: p.title,
+ accent: p.accent,
+ source: p.source,
+ }));
+ renderPersonaOptions(state.personas, preferId || $('sa_persona')?.value);
+ }
+ if (preferId && $('sa_persona')) {
+ if ([...$('sa_persona').options].some((o) => o.value === preferId)) {
+ $('sa_persona').value = preferId;
+ await applyPersonaForChat(preferId, { quiet: true });
+ }
+ } else {
+ loadConfig($('sa_persona')?.value, () => resolve());
+ return;
+ }
+ resolve();
+ },
+ 0,
+ () => resolve(),
+ );
+ });
+ }
+
+ function triggerGenerate() {
+ try {
+ if (typeof mainGenHandler !== 'undefined' && mainGenHandler && typeof mainGenHandler.doGenerate === 'function') {
+ mainGenHandler.doGenerate();
+ return true;
+ }
+ } catch (e) {
+ console.warn('Assistent: doGenerate failed', e);
+ }
+ const btn =
+ document.getElementById('generate_button') ||
+ document.getElementById('alt_generate_button') ||
+ document.querySelector('button.generate-button') ||
+ document.querySelector('#generate_button, button[id*="generate"]');
+ if (btn) {
+ btn.click();
+ return true;
+ }
+ return false;
+ }
+
+ function shouldParkLlmBeforeGen() {
+ return !!$('sa_park_llm')?.checked;
+ }
+
+ /** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */
+ function parkLlm() {
+ return new Promise((resolve) => {
+ const model = $('sa_model')?.value;
+ if (!shouldParkLlmBeforeGen() || !model || state.llmParked || typeof genericRequest !== 'function') {
+ resolve(false);
+ return;
+ }
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ let settled = false;
+ const finish = (ok) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (ok) {
+ state.llmParked = true;
+ state.expectColdLoad = true;
+ }
+ resolve(!!ok);
+ };
+ setTimeout(() => finish(false), 8000);
+ genericRequest('AssistentParkLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
+ });
+ }
+
+ /** Re-load chat model into VRAM. force=true after Generate even without park — Krea often evicts Ollama. */
+ function warmLlm({ force = false } = {}) {
+ return new Promise((resolve) => {
+ const model = $('sa_model')?.value;
+ if (!model || typeof genericRequest !== 'function') {
+ resolve(false);
+ return;
+ }
+ if (!force && !state.llmParked) {
+ resolve(false);
+ return;
+ }
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ let settled = false;
+ const finish = (ok) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ state.llmParked = false;
+ if (ok) {
+ state.expectColdLoad = false;
+ }
+ resolve(!!ok);
+ };
+ // VL cold-load can exceed a minute — don't time out the flag early.
+ setTimeout(() => finish(false), 180000);
+ genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
+ });
+ }
+
+ function cancelWaitForNewImage() {
+ if (state.waitImageTimer) {
+ clearInterval(state.waitImageTimer);
+ state.waitImageTimer = null;
+ }
+ }
+
+ function bumpChatEpoch() {
+ state.chatEpoch = (state.chatEpoch || 0) + 1;
+ return state.chatEpoch;
+ }
+
+ function clearInFlightUi({ status } = {}) {
+ state.busy = false;
+ state.generating = false;
+ state.pendingSilentGen = false;
+ if (state.streamEl) {
+ try { state.streamEl.remove(); } catch (e) { /* ignore */ }
+ state.streamEl = null;
+ state.streamMeta = null;
+ }
+ setInterruptVisible(false);
+ syncGenerateBusy();
+ syncPatchActionAvailability();
+ if (status != null) {
+ stopBusyUi(status);
+ } else {
+ stopBusyUi('');
+ }
+ }
+
+ /** Invalidate in-flight Assistent WS/wait; optionally also interrupt Swarm Generate. */
+ function abortInFlightWork({ status, interruptSwarm = false } = {}) {
+ bumpChatEpoch();
+ cancelWaitForNewImage();
+ if (interruptSwarm) {
+ try {
+ if (typeof doInterrupt === 'function') {
+ doInterrupt(false);
+ } else if (typeof genericRequest === 'function') {
+ genericRequest('InterruptAll', { other_sessions: false }, () => {}, 0, () => {});
+ }
+ } catch (e) { /* ignore */ }
+ }
+ clearInFlightUi({ status: status != null ? status : '' });
+ }
+
+ function doInterruptNow() {
+ bumpChatEpoch();
+ cancelWaitForNewImage();
+ try {
+ if (typeof doInterrupt === 'function') {
+ doInterrupt(false);
+ return;
+ }
+ } catch (e) { /* ignore */ }
+ if (typeof genericRequest === 'function') {
+ genericRequest('InterruptAll', { other_sessions: false }, () => {}, 0, () => {});
+ }
+ }
+
+ function waitForNewImage(prevSrc, timeoutMs = 180000) {
+ cancelWaitForNewImage();
+ const epoch = state.chatEpoch;
+ const prev = String(prevSrc || '');
+ return new Promise((resolve) => {
+ const start = Date.now();
+ let sawRunning = false;
+ let idleTicks = 0;
+ let candidate = null;
+ state.waitImageTimer = setInterval(() => {
+ if (epoch !== state.chatEpoch) {
+ cancelWaitForNewImage();
+ resolve(null);
+ return;
+ }
+ const running = isSwarmGenerateRunning();
+ if (running) {
+ sawRunning = true;
+ idleTicks = 0;
+ } else if (sawRunning) {
+ idleTicks += 1;
+ }
+ const raw = findCurrentGenerateSrc();
+ const src = raw && !looksLikeModelPreview(raw) ? raw : null;
+ if (src && src !== prev) {
+ candidate = src;
+ }
+ // Primary: Swarm finished after we saw it run — accept current/changed frame
+ // even when ViewImage URL was reused (same string, new bytes).
+ if (sawRunning && !running && idleTicks >= 2) {
+ cancelWaitForNewImage();
+ resolve(candidate || src || null);
+ return;
+ }
+ // Missed the running flag (very fast Turbo): URL changed and Swarm is idle.
+ if (candidate && !running && Date.now() - start > 500) {
+ cancelWaitForNewImage();
+ resolve(candidate);
+ return;
+ }
+ if (Date.now() - start > timeoutMs) {
+ cancelWaitForNewImage();
+ resolve(candidate || src || null);
+ }
+ }, 400);
+ });
+ }
+
+ const VARIANT_STRIP_KEYS = [
+ 'variants', 'label', 'notes', 'actions', 'look_at', 'vision_from', 'vision_slots',
+ 'search_query', 'civitai_query', 'memories', 'memory', 'memory_query', 'memory_kind',
+ 'tag_query', 'user_prefs', 'controls', 'skills', 'persona_shelves', 'inventory_query',
+ 'pack',
+ ];
+
+ /** 2–4 partial patches from the model; fewer than 2 → treat as a normal single Generate. */
+ function normalizeVariantList(patch) {
+ if (!patch || !Array.isArray(patch.variants)) {
+ return null;
+ }
+ const items = patch.variants.filter((v) => v && typeof v === 'object' && !Array.isArray(v));
+ if (items.length < 2) {
+ return null;
+ }
+ return items.slice(0, MAX_GEN_VARIANTS);
+ }
+
+ function stripMetaPatchKeys(obj) {
+ const out = { ...(obj || {}) };
+ for (const k of VARIANT_STRIP_KEYS) {
+ delete out[k];
+ }
+ return out;
+ }
+
+ function mergeVariantPatch(base, item, index) {
+ const merged = { ...stripMetaPatchKeys(base), ...stripMetaPatchKeys(item) };
+ merged.images = 1;
+ delete merged.batch;
+ if (merged.seed == null && merged.lock_seed !== true) {
+ merged.seed = -1;
+ merged.vary = true;
+ }
+ merged.actions = ['generate'];
+ const labelRaw = item?.label != null ? String(item.label).trim() : '';
+ return {
+ id: `var${index + 1}`,
+ label: (labelRaw || `Вариант ${index + 1}`).slice(0, 48),
+ patch: merged,
+ };
+ }
+
+ function finishedGenResultCount() {
+ return (state.genResults || []).filter((r) => r && r.src).length;
+ }
+
+ function isMultiGenResults() {
+ return finishedGenResultCount() > 1 || (state.genResults || []).length > 1;
+ }
+
+ function clearGenResults() {
+ state.genResults = [];
+ state.selectedGenResultId = null;
+ if (state.lightboxIndex >= 0) {
+ closeGenLightbox();
+ }
+ }
+
+ function selectGenResult(id, { restore = true, openViewer = false } = {}) {
+ const row = (state.genResults || []).find((r) => r.id === id);
+ if (!row) {
+ return false;
+ }
+ state.selectedGenResultId = row.id;
+ const gen = generateSlot();
+ if (gen && row.src) {
+ gen.src = row.src;
+ }
+ if (restore && row.patch) {
+ applyPatch(row.patch, 'all').catch(() => {});
+ syncLiveParamsBar();
+ }
+ renderBoard();
+ if (openViewer && row.src) {
+ openGenLightbox(row.id);
+ }
+ return true;
+ }
+
+ async function runGenerateFromPatch(patch, opts = {}) {
+ const force = !!opts.force;
+ if ((!force && !$('sa_auto_generate')?.checked) || !patchHasGenTrigger(patch)) {
+ return null;
+ }
+
+ const variantItems = normalizeVariantList(patch);
+ const jobs = variantItems
+ ? variantItems.map((item, i) => mergeVariantPatch(patch, item, i))
+ : null;
+
+ if (jobs) {
+ state.genResults = jobs.map((j) => ({
+ id: j.id,
+ label: j.label,
+ src: null,
+ patch: j.patch,
+ }));
+ state.selectedGenResultId = null;
+ setBoardTab('generate', { persist: true });
+ renderBoard();
+ } else {
+ clearGenResults();
+ }
+
+ const epoch = state.chatEpoch;
+ if (shouldParkLlmBeforeGen()) {
+ startBusyUi('parking');
+ setStatus('Освобождаю VRAM…');
+ await parkLlm();
+ if (epoch !== state.chatEpoch) {
+ return null;
+ }
+ }
+
+ state.generating = true;
+ setInterruptVisible(true);
+ startBusyUi('generating');
+
+ let lastSrc = null;
+ const steps = jobs || [{
+ id: 'var1',
+ label: 'Generate',
+ patch: { ...stripMetaPatchKeys(patch), actions: ['generate'] },
+ }];
+
+ for (let i = 0; i < steps.length; i++) {
+ if (epoch !== state.chatEpoch) {
+ break;
+ }
+ const job = steps[i];
+ if (jobs) {
+ setStatus(`Вариант ${i + 1}/${steps.length}: ${job.label}`);
+ } else {
+ setStatus('Генерация…');
+ }
+ startBusyUi('generating');
+
+ // Multi: each step applies its merged patch. Single: caller already applied.
+ if (jobs) {
+ await applyPatch(job.patch, 'all');
+ syncLiveParamsBar();
+ }
+
+ if (epoch !== state.chatEpoch) {
+ break;
+ }
+
+ const prev = findCurrentGenerateSrc();
+ const ok = triggerGenerate();
+ if (!ok) {
+ setStatus(jobs
+ ? `Не удалось запустить вариант ${i + 1}/${steps.length}`
+ : 'Не удалось запустить Generate');
+ if (!jobs) {
+ break;
+ }
+ continue;
+ }
+
+ const src = await waitForNewImage(prev);
+ if (epoch !== state.chatEpoch) {
+ break;
+ }
+
+ if (src) {
+ lastSrc = src;
+ if (jobs) {
+ const row = state.genResults.find((r) => r.id === job.id);
+ if (row) {
+ row.src = src;
+ }
+ state.selectedGenResultId = job.id;
+ }
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = src;
+ }
+ renderBoard();
+ }
+ }
+
+ state.generating = false;
+ setInterruptVisible(state.busy);
+ state.expectColdLoad = true;
+
+ if (jobs && lastSrc && state.selectedGenResultId && epoch === state.chatEpoch) {
+ const row = state.genResults.find((r) => r.id === state.selectedGenResultId);
+ if (row?.patch) {
+ await applyPatch(row.patch, 'all');
+ syncLiveParamsBar();
+ }
+ }
+
+ const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
+ const multiDone = !!(jobs && finishedGenResultCount() > 1);
+ const willAutoCritique = !multiDone && !!$('sa_auto_critique')?.checked;
+ if (state.view === 'chat' && paneVisible && !willAutoCritique && epoch === state.chatEpoch) {
+ startBusyUi('warming');
+ setStatus('Возвращаю LLM в GPU…');
+ await warmLlm({ force: true });
+ }
+
+ if (epoch !== state.chatEpoch) {
+ return null;
+ }
+
+ if (jobs) {
+ const n = finishedGenResultCount();
+ const msg = n > 0
+ ? (n > 1 ? `Готово · ${n} вариантов` : `Готово · 1 вариант`)
+ : 'Generate завершён (новое изображение не найдено)';
+ if (!state.busy) {
+ stopBusyUi(msg);
+ }
+ setStatus(msg);
+ // Multi-grid: skip auto-critique / auto look_at (caller checks isMultiGenResults).
+ return multiDone ? null : lastSrc;
+ }
+
+ if (!state.busy) {
+ stopBusyUi(lastSrc ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
+ }
+ if (lastSrc) {
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = lastSrc;
+ renderBoard();
+ }
+ setStatus('Generate готов');
+ return lastSrc;
+ }
+ if (state.busy) {
+ setStatus('Generate завершён (новое изображение не найдено)');
+ }
+ return null;
+ }
+
+ /** Resolves the freshest real Generate frame — never a model preview. */
+ async function resolveFinishedGenerateSrc(hint, { settleMs = 20000 } = {}) {
+ scrubPreviewFromGenerateSlot();
+ let src = hint && !looksLikeModelPreview(hint) ? hint : null;
+ if (!src) {
+ src = findCurrentGenerateSrc();
+ }
+ // Batch still running: the last frame is not the final one yet.
+ if (isGenerateUnavailable()) {
+ const settled = await waitForNewImage(src, settleMs);
+ if (settled) {
+ src = settled;
+ }
+ }
+ return src && !looksLikeModelPreview(src) ? src : null;
+ }
+
+ async function maybeAutoCritique(imageSrc) {
+ if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed || isMultiGenResults()) {
+ return;
+ }
+ const src = await resolveFinishedGenerateSrc(imageSrc);
+ if (!src) {
+ setStatus('Авто-критика пропущена — нет готового кадра Generate');
+ return;
+ }
+ state.critiqueHopUsed = true;
+ setPackValue('critique_image', { flash: true });
+ if ($('sa_input')) {
+ $('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
+ }
+ const gen = generateSlot();
+ if (gen) {
+ gen.attach = true;
+ gen.src = src;
+ renderBoard();
+ }
+ setStatus('Auto-critique…');
+ await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
+ restoreDefaultPackAfterHop();
+ }
+
+ /** After Generate: send look_at with JPEG when sa_auto_vision is on (skipped if auto-critique already attaches vision). */
+ async function maybeAutoVisionLook(imageSrc) {
+ if (!wantsAutoVision() || $('sa_auto_critique')?.checked || state.visionHopUsed || state.busy || isMultiGenResults()) {
+ return;
+ }
+ const src = await resolveFinishedGenerateSrc(imageSrc);
+ if (!src) {
+ return;
+ }
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = src;
+ gen.attach = true;
+ renderBoard();
+ }
+ state.visionHopUsed = true;
+ setPackValue('critique_image', { flash: true });
+ if ($('sa_input')) {
+ $('sa_input').value = 'Look at the Generate result and briefly say what worked and what to fix next.';
+ }
+ setStatus('Auto look_at…');
+ await sendChat({ fromVisionHop: true, forceSlotIds: [GEN_ID], skipAutoPack: true });
+ restoreDefaultPackAfterHop();
+ }
+
+ /** Board action: attach the finished Generate frame and ask for a verdict. */
+ async function askLookAtResult() {
+ if (state.busy || state.generating) {
+ setStatus('Занято — дождись конца ответа или Стоп');
+ return;
+ }
+ if (!updateGate()) {
+ setStatus('Выбери модель Krea 2');
+ return;
+ }
+ const preferred = (state.genResults || []).find((r) => r.id === state.selectedGenResultId && r.src)?.src
+ || generateSlot()?.src;
+ const src = await resolveFinishedGenerateSrc(preferred, { settleMs: 8000 });
+ if (!src) {
+ setStatus('Нет готового кадра Generate — сначала сгенерируй');
+ return;
+ }
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = src;
+ gen.attach = true;
+ }
+ setBoardTab('generate');
+ renderBoard();
+ setView('chat');
+ const label = (state.genResults || []).find((r) => r.id === state.selectedGenResultId)?.label;
+ setPackValue('critique_image', { flash: true });
+ if ($('sa_input')) {
+ $('sa_input').value = label
+ ? `Посмотри результат «${label}»: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.`
+ : 'Посмотри результат: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.';
+ }
+ await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true });
+ }
+
+ function currentPersonaInfo() {
+ const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
+ const known = (state.personas || []).find((p) => p && p.id === id);
+ return {
+ id,
+ title: (known && known.title) || ({
+ neutral: 'Нейтральный',
+ lewd: 'Пошляк',
+ aggressive: 'Агрессивный',
+ }[id] || id),
+ };
+ }
+
+ function mountAssistantMeta(div, meta = {}) {
+ if (!div || div.querySelector('.sa-msg-meta')) {
+ return;
+ }
+ const persona = meta.persona || currentPersonaInfo();
+ const pack = meta.pack || $('sa_pack')?.value || '';
+ div.dataset.persona = persona.id || 'neutral';
+ if (pack) {
+ div.dataset.pack = pack;
+ }
+ const row = document.createElement('div');
+ row.className = 'sa-msg-meta';
+ const chip = document.createElement('span');
+ chip.className = `sa-persona-mark sa-persona-${persona.id || 'neutral'}`;
+ chip.textContent = persona.title || persona.id;
+ chip.title = `Характер: ${persona.title || persona.id}${pack ? ` · режим ${pack}` : ''}`;
+ row.appendChild(chip);
+ if (pack && pack !== 'ordinary' && pack !== 'write_prompt') {
+ const packEl = document.createElement('span');
+ packEl.className = 'sa-pack-mark';
+ packEl.textContent = pack.replace(/_/g, ' ');
+ packEl.title = `Режим: ${pack}`;
+ row.appendChild(packEl);
+ }
+ div.insertBefore(row, div.firstChild);
+ }
+
+ function appendMessage(role, text, patch, civitaiResults, meta) {
+ const box = $('sa_messages');
+ if (!box) {
+ return null;
+ }
+ hideChatEmpty();
+ const div = document.createElement('div');
+ div.className = `sa-msg ${role}`;
+ if (role === 'assistant') {
+ mountAssistantMeta(div, meta);
+ }
+ const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null };
+ const finalPatch = patch || extracted;
+ if (role === 'assistant') {
+ setAssistantBody(div, prose || text || '');
+ } else {
+ div.textContent = prose || text || '';
+ }
+ // Historical replay: show prose only — no Apply strip (lastPatch comes from chat.params).
+ if (finalPatch && !(meta && meta.historical)) {
+ const silent = !!(meta && meta.silentPatch);
+ mountPatchBlock(div, finalPatch, { silent });
+ }
+ if (civitaiResults && civitaiResults.length) {
+ div.appendChild(buildCivitaiCards(civitaiResults));
+ }
+ box.appendChild(div);
+ scrollMessagesToBottom({ force: true });
+ return div;
+ }
+
+ function beginStreamMessage(meta) {
+ const box = $('sa_messages');
+ if (!box) {
+ return null;
+ }
+ hideChatEmpty();
+ const div = document.createElement('div');
+ div.className = 'sa-msg assistant sa-streaming sa-typing';
+ mountAssistantMeta(div, meta);
+ const body = document.createElement('div');
+ body.className = 'sa-msg-body';
+ body.innerHTML = 'Waiting for the model… ';
+ div.appendChild(body);
+ box.appendChild(div);
+ scrollMessagesToBottom({ force: true });
+ state.streamEl = div;
+ state.streamMeta = meta || null;
+ state.streamFenceDone = false;
+ return div;
+ }
+
+ function streamHasClosedPatchFence(text) {
+ const t = String(text || '');
+ if (!/```[\s\S]*```/.test(t)) {
+ return false;
+ }
+ const re = /```(?:json)?\s*([\s\S]*?)```/gi;
+ let match;
+ while ((match = re.exec(t)) !== null) {
+ try {
+ const obj = JSON.parse(match[1].trim());
+ const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
+ ? SA.isTerminalStreamPatch(obj)
+ : (isPatchObject(obj) || isCardObject(obj));
+ if (terminal) {
+ return true;
+ }
+ } catch (e) { /* ignore */ }
+ }
+ return false;
+ }
+
+ function trimToClosedPatchFence(text) {
+ const t = String(text || '');
+ const re = /```(?:json)?\s*([\s\S]*?)```/gi;
+ let match;
+ let lastEnd = -1;
+ while ((match = re.exec(t)) !== null) {
+ try {
+ const obj = JSON.parse(match[1].trim());
+ const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
+ ? SA.isTerminalStreamPatch(obj)
+ : (isPatchObject(obj) || isCardObject(obj));
+ if (terminal) {
+ lastEnd = match.index + match[0].length;
+ }
+ } catch (e) { /* ignore */ }
+ }
+ return lastEnd > 0 ? t.slice(0, lastEnd).trimEnd() : t;
+ }
+
+ function appendStreamDelta(delta) {
+ if (state.streamFenceDone) {
+ return;
+ }
+ if (!state.streamEl) {
+ beginStreamMessage(state.streamMeta || undefined);
+ }
+ if (state.streamEl) {
+ if (state.streamEl.classList.contains('sa-typing')) {
+ state.streamEl.classList.remove('sa-typing');
+ state.streamText = '';
+ setAssistantBody(state.streamEl, '', { live: true });
+ }
+ state.gotDelta = true;
+ state.expectColdLoad = false;
+ if (state.busyPhase !== 'refining') {
+ setBusyPhase('streaming');
+ }
+ state.streamText = (state.streamText || '') + (delta || '');
+ if (streamHasClosedPatchFence(state.streamText)) {
+ state.streamText = trimToClosedPatchFence(state.streamText);
+ state.streamFenceDone = true;
+ }
+ setAssistantBody(state.streamEl, state.streamText, { live: true });
+ scrollMessagesToBottom();
+ }
+ }
+
+ function finalizeStreamMessage(fullReply, civitaiResults) {
+ const el = state.streamEl;
+ const meta = state.streamMeta;
+ state.streamEl = null;
+ state.streamMeta = null;
+ state.streamText = '';
+ state.streamFenceDone = false;
+ if (!el) {
+ appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined);
+ return;
+ }
+ el.classList.remove('sa-streaming', 'sa-typing');
+ mountAssistantMeta(el, meta || undefined);
+ const card = extractCardJson(fullReply);
+ const { prose, patch } = extractPatch(fullReply);
+ setAssistantBody(el, prose || fullReply || '');
+ el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove());
+ if (patch && !isCardObject(patch) && !(card && !patch.prompt && !patch.actions && !patch.loras)) {
+ const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
+ mountPatchBlock(el, patch, { silent });
+ } else if (card) {
+ const wrap = document.createElement('div');
+ wrap.className = 'sa-patch sa-card-json-preview';
+ const pre = document.createElement('pre');
+ pre.textContent = JSON.stringify(card, null, 2);
+ wrap.appendChild(pre);
+ el.appendChild(wrap);
+ }
+ if (civitaiResults && civitaiResults.length) {
+ el.appendChild(buildCivitaiCards(civitaiResults));
+ }
+ scrollMessagesToBottom();
+ }
+
+ function buildCivitaiCards(results) {
+ const list = document.createElement('div');
+ list.className = 'sa-civitai-list';
+ for (const r of results) {
+ const card = document.createElement('div');
+ card.className = 'sa-civitai-card' + (r.already_installed ? ' sa-installed' : '');
+ const title = document.createElement('div');
+ title.className = 'sa-civitai-title';
+ title.textContent = r.name || r.file_name || 'LoRA';
+ card.appendChild(title);
+ const meta = document.createElement('div');
+ meta.className = 'sa-civitai-meta';
+ const bits = [
+ r.base_model || '?',
+ r.krea_likely ? 'Krea?' : null,
+ r.already_installed ? 'already installed' : null,
+ (r.triggers || []).slice(0, 3).join(', ') || null,
+ ].filter(Boolean);
+ meta.textContent = bits.join(' · ');
+ card.appendChild(meta);
+ const actions = document.createElement('div');
+ actions.className = 'sa-civitai-actions';
+ if (r.already_installed) {
+ const note = document.createElement('span');
+ note.textContent = 'Уже установлена';
+ actions.appendChild(note);
+ } else if (r.download_url) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'basic-button sa-primary';
+ btn.textContent = 'Подтвердить скачивание';
+ btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
+ actions.appendChild(btn);
+ } else {
+ const note = document.createElement('span');
+ note.textContent = 'Нет URL скачивания';
+ actions.appendChild(note);
+ }
+ card.appendChild(actions);
+ list.appendChild(card);
+ }
+ return list;
+ }
+
+ function downloadCivitaiLoRA(card, btn) {
+ if (!card.download_url) {
+ return;
+ }
+ if (btn) {
+ btn.disabled = true;
+ btn.textContent = 'Скачиваю…';
+ }
+ setStatus(`Скачиваю ${card.file_name || card.name}…`);
+ setInterruptVisible(true);
+ const payload = {
+ url: card.download_url,
+ type: 'LoRA',
+ name: card.file_name || card.name || 'lora',
+ };
+ const onDone = (ok, msg) => {
+ setInterruptVisible(state.busy || state.generating);
+ if (ok) {
+ setStatus(`Скачано ${payload.name}`);
+ if (btn) {
+ btn.textContent = 'Скачано';
+ }
+ refreshInventory(async () => {
+ await maybeWriteCardAfterDownload({
+ kind: 'lora',
+ name: payload.name,
+ civitai: card,
+ });
+ }, { rescan: true });
+ } else {
+ setStatus(msg || 'Ошибка скачивания');
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = 'Подтвердить скачивание';
+ }
+ appendMessage('error', msg || 'Ошибка скачивания');
+ }
+ };
+ if (typeof makeWSRequest === 'function') {
+ makeWSRequest(
+ 'DoModelDownloadWS',
+ payload,
+ (data) => {
+ if (data.error) {
+ onDone(false, String(data.error));
+ return;
+ }
+ if (data.success || data.overall_percent >= 1 || data.current_percent >= 1) {
+ if (data.success || data.overall_percent >= 0.99) {
+ // Swarm docs: download does not always refresh model list — force both.
+ triggerSwarmModelRefresh(() => onDone(true));
+ } else if (data.current_percent != null) {
+ setStatus(`Download ${(data.current_percent * 100).toFixed(0)}%`);
+ }
+ }
+ },
+ 0,
+ (err) => onDone(false, String(err || 'Download failed')),
+ );
+ } else {
+ onDone(false, 'makeWSRequest unavailable');
+ }
+ }
+
+ async function maybeWriteCardAfterDownload({ kind, name, civitai }) {
+ const display = name || civitai?.file_name || civitai?.name || 'model';
+ appendSystemNote(`Downloaded ${display}. Writing a recommendation card…`);
+ setPackValue('catalog_card', { flash: true });
+ const meta = {
+ triggers: civitai?.triggers || [],
+ base_model: civitai?.base_model,
+ civitai_url: civitai?.url || civitai?.civitai_url,
+ version_id: civitai?.version_id || civitai?.modelVersionId,
+ name: display,
+ };
+ if ($('sa_input')) {
+ $('sa_input').value = '';
+ }
+ await sendChat({
+ forcedUserText: `LoRA "${display}" is now installed. Write a recommendation card (JSON) using its triggers/metadata. Then briefly suggest how to enable it in the next generate.`,
+ skipSlash: true,
+ skipAutoPack: true,
+ fromDownload: true,
+ fromCards: true,
+ cardTarget: { kind: kind || 'lora', name: display, meta },
+ });
+ }
+
+ function wantsAutoVision() {
+ return !!$('sa_auto_vision')?.checked;
+ }
+
+ function looksLikeModelPreview(src) {
+ const s = String(src || '').toLowerCase();
+ if (!s) {
+ return false;
+ }
+ // Only SwarmUI checkpoint/LoRA card routes — not bare "/models/" (matches Civitai page URLs).
+ return s.includes('.preview.')
+ || s.includes('placeholder')
+ || s.includes('/viewspecial/')
+ || s.includes('viewspecial/')
+ || s.includes('/view/models/')
+ || /\/view\/models\//.test(s)
+ || /[?&](?:path|file)=[^&]*\.preview\./i.test(s);
+ }
+
+ function findCurrentGenerateSrc({ allowPreview = false } = {}) {
+ let src = null;
+ try {
+ const cur = document.getElementById('current_image_img')
+ || document.querySelector('#current_image img')
+ || document.querySelector('.current-image img')
+ || document.querySelector('#current_image_batch img');
+ if (cur) {
+ src = cur.dataset?.src || cur.getAttribute?.('data-src') || cur.src || null;
+ }
+ } catch (e) { /* ignore */ }
+ if (!src) {
+ try {
+ if (typeof currentMetadataMap !== 'undefined' && currentMetadataMap && currentMetadataMap.image) {
+ src = currentMetadataMap.image;
+ }
+ } catch (e) { /* ignore */ }
+ }
+ if (!src) {
+ return null;
+ }
+ // Strip cache-busters for classification, keep original for display when accepted.
+ if (!allowPreview && looksLikeModelPreview(src)) {
+ return null;
+ }
+ return src;
+ }
+
+ function scrubPreviewFromGenerateSlot() {
+ const slot = generateSlot();
+ if (!slot?.src) {
+ return false;
+ }
+ if (!looksLikeModelPreview(slot.src)) {
+ return false;
+ }
+ slot.src = null;
+ slot.attach = false;
+ syncLastImageAlias();
+ return true;
+ }
+
+ function refreshImagePreview() {
+ scrubPreviewFromGenerateSlot();
+ if (wantsAutoVision()) {
+ const gen = generateSlot();
+ if (gen) {
+ const src = findCurrentGenerateSrc();
+ if (src) {
+ gen.attach = true;
+ gen.src = src;
+ } else {
+ gen.attach = false;
+ }
+ renderBoard();
+ }
+ }
+ syncGenerateSlot();
+ }
+
+ function fileToDataUrl(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ''));
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+ }
+
+ async function acceptImageFile(file, slotId) {
+ if (!file || !String(file.type || '').startsWith('image/')) {
+ setStatus('Not an image file');
+ return false;
+ }
+ const dataUrl = await fileToDataUrl(file);
+ if (slotId) {
+ return setSlotSrc(slotId, dataUrl, { note: `Loaded ${file.name || 'image'}` });
+ }
+ return putImageOnBoard(dataUrl, { note: `Loaded ${file.name || 'image'}` });
+ }
+
+ async function handleDropDataTransfer(dt, slotId) {
+ if (!dt) {
+ return false;
+ }
+ if (dt.files && dt.files.length) {
+ for (const file of dt.files) {
+ if (String(file.type || '').startsWith('image/')) {
+ return acceptImageFile(file, slotId);
+ }
+ }
+ }
+ const uri = (dt.getData('text/uri-list') || dt.getData('text/plain') || '').trim();
+ if (uri) {
+ const first = uri.split('\n').map((l) => l.trim()).find((l) => l && !l.startsWith('#'));
+ if (first) {
+ if (slotId) {
+ return setSlotSrc(slotId, first, { note: 'Image from drag' });
+ }
+ return putImageOnBoard(first, { note: 'Image from drag' });
+ }
+ }
+ const html = dt.getData('text/html') || '';
+ const m = html.match(/src=["']([^"']+)["']/i);
+ if (m && m[1]) {
+ if (slotId) {
+ return setSlotSrc(slotId, m[1], { note: 'Image from drag' });
+ }
+ return putImageOnBoard(m[1], { note: 'Image from drag' });
+ }
+ return false;
+ }
+
+ async function imageToBase64ForOllama(src, maxEdge = 1024) {
+ if (!src) {
+ return null;
+ }
+ const dataUrl = await srcToDataUrl(src);
+ if (!dataUrl) {
+ return null;
+ }
+ try {
+ const img = await new Promise((resolve, reject) => {
+ const el = new Image();
+ el.onload = () => resolve(el);
+ el.onerror = reject;
+ el.src = dataUrl;
+ });
+ const w = img.naturalWidth || img.width || 0;
+ const h = img.naturalHeight || img.height || 0;
+ const edge = Math.max(w, h);
+ const canvas = document.createElement('canvas');
+ if (!edge || edge <= maxEdge) {
+ canvas.width = Math.max(w, 1);
+ canvas.height = Math.max(h, 1);
+ canvas.getContext('2d').drawImage(img, 0, 0);
+ } else {
+ const scale = maxEdge / edge;
+ canvas.width = Math.max(1, Math.round(w * scale));
+ canvas.height = Math.max(1, Math.round(h * scale));
+ canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
+ }
+ const jpeg = canvas.toDataURL('image/jpeg', 0.85);
+ const i = jpeg.indexOf(',');
+ return i >= 0 ? jpeg.slice(i + 1) : null;
+ } catch (e) {
+ console.warn('Assistent: vision resize failed', e);
+ const i = dataUrl.indexOf(',');
+ return i >= 0 ? dataUrl.slice(i + 1) : null;
+ }
+ }
+
+ async function srcToDataUrl(src) {
+ if (src.startsWith('data:')) {
+ return src;
+ }
+ try {
+ const resp = await fetch(src);
+ const blob = await resp.blob();
+ return await new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result || ''));
+ reader.onerror = reject;
+ reader.readAsDataURL(blob);
+ });
+ } catch (e) {
+ console.warn('Assistent: vision fetch failed', e);
+ return null;
+ }
+ }
+
+ function loadSettings() {
+ // One-shot: old default was write_prompt → migrate to ordinary комбайн.
+ if (!localStorage.getItem(LS_PACK_ORDINARY_MIG)) {
+ if (localStorage.getItem(LS_PACK) === 'write_prompt') {
+ localStorage.setItem(LS_PACK, 'ordinary');
+ }
+ localStorage.setItem(LS_PACK_ORDINARY_MIG, '1');
+ }
+ const base = localStorage.getItem(LS_BASE);
+ const model = localStorage.getItem(LS_MODEL);
+ const pack = localStorage.getItem(LS_PACK);
+ const persona = localStorage.getItem(LS_PERSONA);
+ const view = localStorage.getItem(LS_VIEW);
+ const auto = localStorage.getItem(LS_AUTO_VISION);
+ const autoApply = localStorage.getItem(LS_AUTO_APPLY);
+ const autoGen = localStorage.getItem(LS_AUTO_GENERATE);
+ const autoCrit = localStorage.getItem(LS_AUTO_CRITIQUE);
+ const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD);
+ const parkLlm = localStorage.getItem(LS_PARK_LLM);
+ const paneW = localStorage.getItem(LS_PANE_WIDTH);
+ if (base && $('sa_base_url')) {
+ $('sa_base_url').value = base;
+ }
+ if (pack && $('sa_pack')) {
+ $('sa_pack').value = pack;
+ }
+ if (persona && $('sa_persona')) {
+ $('sa_persona').value = persona;
+ }
+ if (auto != null && $('sa_auto_vision')) {
+ $('sa_auto_vision').checked = auto === '1';
+ }
+ if ($('sa_auto_apply')) {
+ $('sa_auto_apply').checked = autoApply == null ? true : autoApply === '1';
+ }
+ if ($('sa_auto_generate')) {
+ $('sa_auto_generate').checked = autoGen == null ? true : autoGen === '1';
+ }
+ if ($('sa_auto_critique') && autoCrit != null) {
+ $('sa_auto_critique').checked = autoCrit === '1';
+ }
+ if ($('sa_auto_download') && autoDl != null) {
+ $('sa_auto_download').checked = autoDl === '1';
+ }
+ // Default OFF — parking a VL 7B before every Generate caused 1–2 min reloads.
+ if ($('sa_park_llm')) {
+ $('sa_park_llm').checked = parkLlm === '1';
+ }
+ if (model) {
+ state.preferredModel = model;
+ }
+ const embed = localStorage.getItem(LS_EMBED);
+ if (embed) {
+ state.preferredEmbed = embed;
+ }
+ if (paneW) {
+ document.documentElement.style.setProperty('--sa-image-width', paneW);
+ }
+ if (view === 'cards' || view === 'chat' || view === 'settings') {
+ state.view = view;
+ }
+ const boardTab = localStorage.getItem(LS_BOARD_TAB);
+ if (boardTab === 'refs' || boardTab === 'generate') {
+ state.boardTab = boardTab;
+ }
+ }
+
+ function collectUiState() {
+ return {
+ pack: $('sa_pack')?.value || defaultPackId(),
+ persona: $('sa_persona')?.value || 'neutral',
+ auto_vision: !!$('sa_auto_vision')?.checked,
+ auto_apply: !!$('sa_auto_apply')?.checked,
+ auto_generate: !!$('sa_auto_generate')?.checked,
+ auto_critique: !!$('sa_auto_critique')?.checked,
+ auto_download: !!$('sa_auto_download')?.checked,
+ park_llm: !!$('sa_park_llm')?.checked,
+ pane_width: localStorage.getItem(LS_PANE_WIDTH) || '',
+ embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
+ base_url: $('sa_base_url')?.value || '',
+ model: $('sa_model')?.value || '',
+ view: state.view || 'chat',
+ board_tab: state.boardTab || 'generate',
+ };
+ }
+
+ /**
+ * Fills fields the browser has never seen from sqlite ui_state, so a fresh
+ * browser on the same volume inherits the previous session. Existing localStorage wins.
+ * auto_download is only ever restored when it is off — the danger flag stays opt-in.
+ */
+ async function applyDiskUiState() {
+ const persist = diskPersist();
+ if (!persist) {
+ return;
+ }
+ let ui = null;
+ try {
+ ui = await persist.loadUiState();
+ } catch (e) {
+ return;
+ }
+ if (!ui || typeof ui !== 'object') {
+ return;
+ }
+ const fill = (lsKey, value, apply) => {
+ if (value == null || value === '' || localStorage.getItem(lsKey) != null) {
+ return;
+ }
+ localStorage.setItem(lsKey, String(value));
+ apply?.(String(value));
+ };
+ fill(LS_BASE, ui.base_url, (v) => { if ($('sa_base_url')) { $('sa_base_url').value = v; } });
+ fill(LS_MODEL, ui.model, (v) => { state.preferredModel = v; });
+ fill(LS_EMBED, ui.embed_model, (v) => { state.preferredEmbed = v; });
+ fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
+ fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
+ fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
+ if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') {
+ fill(LS_VIEW, ui.view, (v) => { state.view = v; });
+ }
+ if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
+ fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
+ }
+ for (const [key, lsKey, id] of [
+ ['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
+ ['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
+ ['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'],
+ ['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'],
+ ['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'],
+ ['park_llm', LS_PARK_LLM, 'sa_park_llm'],
+ ]) {
+ if (ui[key] == null || localStorage.getItem(lsKey) != null) {
+ continue;
+ }
+ const on = ui[key] === true || ui[key] === '1' || ui[key] === 1;
+ if (on && key === 'auto_download') {
+ continue;
+ }
+ localStorage.setItem(lsKey, on ? '1' : '0');
+ const el = $(id);
+ if (el) {
+ el.checked = on;
+ }
+ }
+ }
+
+ function saveUiStateToDisk() {
+ diskPersist()?.saveUiState(collectUiState());
+ }
+
+ function saveSettings() {
+ localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
+ localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
+ localStorage.setItem(LS_EMBED, $('sa_embed_model')?.value || state.preferredEmbed || '');
+ localStorage.setItem(LS_PACK, $('sa_pack')?.value || defaultPackId());
+ localStorage.setItem(LS_PERSONA, $('sa_persona')?.value || 'neutral');
+ localStorage.setItem(LS_VIEW, state.view || 'chat');
+ localStorage.setItem(LS_AUTO_VISION, $('sa_auto_vision')?.checked ? '1' : '0');
+ localStorage.setItem(LS_AUTO_APPLY, $('sa_auto_apply')?.checked ? '1' : '0');
+ localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0');
+ localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
+ localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
+ localStorage.setItem(LS_PARK_LLM, $('sa_park_llm')?.checked ? '1' : '0');
+ persistServerSettings();
+ saveUiStateToDisk();
+ }
+
+ function persistServerSettings() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const skills = {};
+ document.querySelectorAll('#sa_skills_box input[data-skill]')?.forEach((el) => {
+ skills[el.getAttribute('data-skill')] = !!el.checked;
+ });
+ const persona = $('sa_persona')?.value || 'neutral';
+ const settings = {
+ embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
+ base_url: $('sa_base_url')?.value || '',
+ [persona]: { skills },
+ };
+ genericRequest('AssistentSaveSettings', { settings }, () => {}, 0, () => {});
+ }
+
+ function applyConfigPayload(data, { applyDefaults = false } = {}) {
+ if (!data || data.error) {
+ return;
+ }
+ const prevPersona = state.config?.persona || $('sa_persona')?.value || '';
+ const prevControls = state.config?.control_values && typeof state.config.control_values === 'object'
+ ? { ...state.config.control_values }
+ : null;
+ state.config = data;
+ if (data.exact && typeof data.exact === 'object') {
+ state.exact = data.exact;
+ }
+ const aspectSource = data.exact?.aspect_table || data.model?.aspect_table;
+ if (aspectSource && typeof aspectSource === 'object') {
+ applyAspectTableFrom(aspectSource);
+ }
+ const profileSource = data.exact?.profiles || data.model?.profiles;
+ if (profileSource && typeof profileSource === 'object') {
+ state.kreaProfiles = profileSource;
+ }
+ if (data.ui?.pack_aliases) {
+ PACK_ALIASES = { ...PACK_ALIASES, ...data.ui.pack_aliases };
+ }
+ if (data.ui?.welcome_html) {
+ WELCOME_HTML = data.ui.welcome_html;
+ }
+ if (data.ui?.help_text) {
+ HELP_TEXT = data.ui.help_text;
+ }
+ if (Array.isArray(data.ui?.slash) && data.ui.slash.length) {
+ SLASH_COMMANDS = data.ui.slash.map((s) => ({
+ cmd: s.cmd || '',
+ hint: s.hint || '',
+ action: s.action || '',
+ }));
+ }
+ if (Array.isArray(data.ui?.slash_extra) && data.ui.slash_extra.length) {
+ for (const s of data.ui.slash_extra) {
+ const cmd = s.cmd || '';
+ if (!cmd || SLASH_COMMANDS.some((c) => c.cmd === cmd)) {
+ continue;
+ }
+ SLASH_COMMANDS.push({
+ cmd,
+ hint: s.hint || '',
+ action: s.action || '',
+ });
+ }
+ }
+ if (data.ui?.help_extra) {
+ HELP_TEXT = `${HELP_TEXT || ''}\n\n${data.ui.help_extra}`.trim();
+ }
+ state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : [];
+ if (Array.isArray(data.personas)) {
+ state.personas = data.personas;
+ }
+ renderPersonaOptions(data.personas || [], data.persona || data.default_persona);
+ renderPackOptions(data.packs || [], applyDefaults ? data.assistant?.default_pack : null);
+ renderChips(data.ui?.chips || []);
+ renderSkillChecks(data.skills || [], state.enabledSkills);
+ if (applyDefaults && data.assistant?.default_pack && $('sa_pack') && !localStorage.getItem(LS_PACK)) {
+ $('sa_pack').value = data.assistant.default_pack;
+ }
+ if (data.assistant?.embed_model && !state.preferredEmbed) {
+ state.preferredEmbed = data.assistant.embed_model;
+ }
+ const asst = data.assistant || {};
+ if (asst.history_keep_turns != null) {
+ HISTORY_KEEP_TURNS = Math.max(1, Number(asst.history_keep_turns) || 4);
+ }
+ if (asst.max_ref_slots != null) {
+ MAX_REF_SLOTS = Math.max(1, Number(asst.max_ref_slots) || 4);
+ }
+ if (asst.max_gen_variants != null) {
+ MAX_GEN_VARIANTS = Math.max(2, Math.min(8, Number(asst.max_gen_variants) || 4));
+ }
+ if (asst.context_prompt_max != null) {
+ CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
+ }
+ if (asst.inventory_prompt_rich != null) {
+ INVENTORY_PROMPT_RICH = Math.max(4, Number(asst.inventory_prompt_rich) || 12);
+ }
+ if (asst.inventory_prompt_names != null) {
+ INVENTORY_PROMPT_NAMES = Math.max(INVENTORY_PROMPT_RICH, Number(asst.inventory_prompt_names) || 24);
+ }
+ fillKnobsFromConfig(data);
+ if (applyDefaults || data.exact) {
+ fillEmptyParamsFromExact();
+ }
+ const nextPersona = data.persona || $('sa_persona')?.value || '';
+ let controlValues = data.control_values || data.exact?.controls || {};
+ // Same persona refresh must not wipe in-flight slider drags / optimistic saves with disk defaults.
+ if (!applyDefaults && prevControls && nextPersona === prevPersona) {
+ controlValues = { ...controlValues, ...prevControls };
+ state.config.control_values = controlValues;
+ if (state.exact) {
+ state.exact.controls = { ...(state.exact.controls || {}), ...prevControls };
+ }
+ }
+ renderPersonaControls(data.controls || {}, controlValues);
+ syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $('sa_persona')?.value))?.source);
+ }
+
+ function syncPersonaDeleteButton(source) {
+ const btn = $('sa_persona_delete');
+ if (!btn) {
+ return;
+ }
+ const src = String(source || '');
+ const canDelete = src === 'overlay' || src === 'overlay+bundled';
+ btn.hidden = !canDelete;
+ btn.disabled = !canDelete;
+ }
+
+ let controlSaveTimer = null;
+ let controlsPointerDown = false;
+ let pendingControlsRender = null;
+
+ function renderPersonaControls(schema, values) {
+ const box = $('sa_persona_controls');
+ if (!box) {
+ return;
+ }
+ // Don't rebuild DOM while the user is dragging — that snaps the thumb back.
+ if (controlsPointerDown) {
+ pendingControlsRender = { schema, values };
+ return;
+ }
+ pendingControlsRender = null;
+ box.innerHTML = '';
+ const keys = schema && typeof schema === 'object' ? Object.keys(schema) : [];
+ if (!keys.length) {
+ box.hidden = true;
+ return;
+ }
+ box.hidden = false;
+ const ordered = keys.slice().sort((a, b) => {
+ const oa = Number(schema[a]?.order ?? 100);
+ const ob = Number(schema[b]?.order ?? 100);
+ if (oa !== ob) {
+ return oa - ob;
+ }
+ return String(a).localeCompare(String(b));
+ });
+ for (const id of ordered) {
+ const def = schema[id];
+ if (!def || typeof def !== 'object') {
+ continue;
+ }
+ if (String(def.type || 'slider').toLowerCase() !== 'slider') {
+ continue;
+ }
+ const min = Number(def.min ?? -1);
+ const max = Number(def.max ?? 1);
+ const step = Number(def.step ?? 0.05);
+ const defVal = Number(def.default ?? 0);
+ let cur = values && values[id] != null ? Number(values[id]) : defVal;
+ if (Number.isNaN(cur)) {
+ cur = defVal;
+ }
+ const asPercent = String(def.display || '').toLowerCase() === 'percent';
+ const fmt = (v) => (asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2));
+ const row = document.createElement('div');
+ row.className = 'sa-control-row';
+ row.title = def.hint || id;
+ const lab = document.createElement('label');
+ lab.textContent = def.label || id;
+ const input = document.createElement('input');
+ input.type = 'range';
+ input.min = String(min);
+ input.max = String(max);
+ input.step = String(step);
+ input.value = String(cur);
+ input.dataset.controlId = id;
+ const valEl = document.createElement('span');
+ valEl.className = 'sa-control-val';
+ valEl.textContent = fmt(cur);
+ const applyLocal = (v) => {
+ valEl.textContent = fmt(v);
+ if (state.config) {
+ state.config.control_values = { ...(state.config.control_values || {}), [id]: v };
+ }
+ if (state.exact) {
+ state.exact.controls = { ...(state.exact.controls || {}), [id]: v };
+ }
+ };
+ input.addEventListener('pointerdown', () => {
+ controlsPointerDown = true;
+ });
+ const endPointer = () => {
+ const v = Number(input.value);
+ applyLocal(v);
+ controlsPointerDown = false;
+ // Discard mid-drag rebuilds that carried stale server defaults — keep local values.
+ if (pendingControlsRender) {
+ const schema = pendingControlsRender.schema;
+ pendingControlsRender = null;
+ renderPersonaControls(
+ schema,
+ state.config?.control_values || state.exact?.controls || {},
+ );
+ }
+ if (controlSaveTimer) {
+ clearTimeout(controlSaveTimer);
+ }
+ controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
+ };
+ input.addEventListener('pointerup', endPointer);
+ input.addEventListener('pointercancel', endPointer);
+ // Live label while dragging; also persist on change for keyboard tweaks.
+ input.addEventListener('input', () => {
+ applyLocal(Number(input.value));
+ });
+ input.addEventListener('change', () => {
+ const v = Number(input.value);
+ applyLocal(v);
+ if (controlSaveTimer) {
+ clearTimeout(controlSaveTimer);
+ }
+ controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
+ });
+ row.appendChild(lab);
+ row.appendChild(input);
+ row.appendChild(valEl);
+ box.appendChild(row);
+ }
+ }
+
+ function getControlValue(id, fallback) {
+ const v = state.config?.control_values?.[id] ?? state.exact?.controls?.[id];
+ const n = Number(v);
+ return Number.isFinite(n) ? n : fallback;
+ }
+
+ function coolDownHorny() {
+ const persona = $('sa_persona')?.value || '';
+ if (persona !== 'leonid') {
+ setStatus('/остынь только для Leonid');
+ return;
+ }
+ state.lastUserControlIntent = true;
+ const schema = state.config?.controls || {};
+ if (!schema.horny) {
+ setStatus('У этой личности нет слайдера Хорни');
+ return;
+ }
+ const min = Number(schema.horny.min ?? 0);
+ const max = Number(schema.horny.max ?? 100);
+ const cur = getControlValue('horny', Number(schema.horny.default ?? 35));
+ const next = Math.max(min, Math.min(max, cur - 30));
+ const values = {
+ ...(state.config?.control_values || state.exact?.controls || {}),
+ horny: next,
+ };
+ if (state.config) {
+ state.config.control_values = values;
+ }
+ if (state.exact) {
+ state.exact.controls = values;
+ }
+ renderPersonaControls(schema, values);
+ savePersonaControls({ horny: next });
+ appendSystemNote(`Хорни: ${Math.round(cur)}% → ${Math.round(next)}% (−30)`);
+ setStatus(`/остынь → ${Math.round(next)}%`);
+ }
+
+ async function startHornyGame() {
+ const persona = $('sa_persona')?.value || '';
+ if (persona !== 'leonid') {
+ setStatus('/horny-game только для Leonid');
+ return;
+ }
+ const cur = getControlValue('horny', 35);
+ state.lastUserControlIntent = true;
+ await sendChat({
+ skipSlash: true,
+ skipAutoPack: true,
+ forcedUserText:
+ `Команда /horny-game. Текущий controls.horny = ${Math.round(cur)} (0–100).\n` +
+ `Оцени, насколько вкусы пользователя в этом чате / последнем сообщении совпадают с твоими (roleplay, outfits, realism, fetishes).\n` +
+ `Поставь новый controls.horny: умножь/сдвинь текущее значение пропорционально «насколько тебе это зашло» ` +
+ `(слабое совпадение → чуть вниз или почти без изменений; сильное → заметный рост, clamp 0–100).\n` +
+ `В прозе скажи кратко: совпало ли, какой множитель/сдвиг и новый %. ` +
+ `Обязателен JSON patch с "controls": { "horny": }. Без generate, если не просили картинку.`,
+ });
+ setStatus('/horny-game…');
+ }
+
+ function savePersonaControls(partial) {
+ const persona = $('sa_persona')?.value || 'neutral';
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ // Optimistic local merge so UI / next chat see the new values immediately.
+ if (partial && typeof partial === 'object') {
+ if (state.config) {
+ state.config.control_values = { ...(state.config.control_values || {}), ...partial };
+ }
+ if (state.exact) {
+ state.exact.controls = { ...(state.exact.controls || {}), ...partial };
+ }
+ }
+ genericRequest(
+ 'AssistentSaveControls',
+ { persona, controls: partial || {} },
+ (data) => {
+ if (data?.error) {
+ setStatus(data.error);
+ return;
+ }
+ if (data?.control_values && state.config) {
+ state.config.control_values = data.control_values;
+ if (state.exact) {
+ state.exact.controls = data.control_values;
+ }
+ }
+ // Do not rebuild slider DOM here — that interrupts an in-progress drag and snaps values back.
+ // Update live inputs in place if present and not being dragged.
+ if (!controlsPointerDown && data?.control_values) {
+ syncPersonaControlInputs(data.control_values);
+ }
+ },
+ 0,
+ () => setStatus('controls save failed'),
+ );
+ }
+
+ function syncPersonaControlInputs(values) {
+ const box = $('sa_persona_controls');
+ if (!box || !values || typeof values !== 'object') {
+ return;
+ }
+ box.querySelectorAll('input[data-control-id]').forEach((input) => {
+ const id = input.dataset.controlId;
+ if (values[id] == null) {
+ return;
+ }
+ const v = Number(values[id]);
+ if (!Number.isFinite(v) || input.value === String(v)) {
+ return;
+ }
+ input.value = String(v);
+ const valEl = input.parentElement?.querySelector('.sa-control-val');
+ if (valEl) {
+ const schema = state.config?.controls?.[id];
+ const asPercent = String(schema?.display || '').toLowerCase() === 'percent';
+ valEl.textContent = asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2);
+ }
+ });
+ }
+
+ async function deleteCurrentOverlayPersona() {
+ const id = $('sa_persona')?.value;
+ if (!id) {
+ return;
+ }
+ const meta = (state.personas || []).find((p) => p.id === id);
+ const title = meta?.title || id;
+ const src = meta?.source || state.config?.persona_source || '';
+ if (src !== 'overlay' && src !== 'overlay+bundled') {
+ setStatus('Bundled personas cannot be deleted');
+ return;
+ }
+ if (!window.confirm(`Удалить «${title}»?\nПоставка (bundled) не трогается.`)) {
+ return;
+ }
+ await new Promise((resolve) => {
+ genericRequest(
+ 'AssistentDeletePersona',
+ { persona: id },
+ async (data) => {
+ if (data?.error) {
+ setStatus(data.error);
+ resolve();
+ return;
+ }
+ const next = data?.default_persona || 'neutral';
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ }
+ renderPersonaOptions(state.personas || [], next);
+ if ($('sa_persona')) {
+ $('sa_persona').value = next;
+ }
+ await applyPersonaForChat(next, { quiet: false });
+ setStatus(`Удалено: ${id}`);
+ resolve();
+ },
+ 0,
+ () => { setStatus('delete failed'); resolve(); },
+ );
+ });
+ }
+
+ function renderPersonaOptions(personas, selected) {
+ const sel = $('sa_persona');
+ if (!sel) {
+ return;
+ }
+ const cur = selected || sel.value || localStorage.getItem(LS_PERSONA) || 'neutral';
+ sel.innerHTML = '';
+ for (const p of personas) {
+ const opt = document.createElement('option');
+ opt.value = p.id;
+ opt.textContent = p.title || p.id;
+ if (p.accent) {
+ opt.dataset.accent = p.accent;
+ }
+ sel.appendChild(opt);
+ }
+ if ([...sel.options].some((o) => o.value === cur)) {
+ sel.value = cur;
+ }
+ const meta = (personas || []).find((p) => p.id === sel.value);
+ syncPersonaDeleteButton(meta?.source || state.config?.persona_source);
+ }
+
+ function renderPackOptions(packs, preferred) {
+ const sel = $('sa_pack');
+ if (!sel) {
+ return;
+ }
+ const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || defaultPackId();
+ sel.innerHTML = '';
+ const list = (packs || []).slice().sort((a, b) => (a.order || 100) - (b.order || 100));
+ for (const p of list) {
+ const opt = document.createElement('option');
+ opt.value = p.id;
+ opt.textContent = p.title || p.id;
+ sel.appendChild(opt);
+ }
+ if ([...sel.options].some((o) => o.value === cur)) {
+ sel.value = cur;
+ }
+ }
+
+ function renderChips(chips) {
+ const box = $('sa_chips');
+ if (!box || !Array.isArray(chips) || !chips.length) {
+ return;
+ }
+ box.innerHTML = '';
+ for (const c of chips) {
+ if (c.sep) {
+ const sep = document.createElement('span');
+ sep.className = 'sa-chip-sep';
+ sep.setAttribute('aria-hidden', 'true');
+ box.appendChild(sep);
+ continue;
+ }
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-chip';
+ btn.textContent = c.label || c.value || '';
+ if (c.title) {
+ btn.title = c.title;
+ }
+ const action = c.action || '';
+ const value = c.value ?? '';
+ if (action === 'aspect') {
+ btn.setAttribute('data-aspect', value);
+ } else if (action === 'seed') {
+ btn.setAttribute('data-seed', value);
+ } else if (action === 'vary') {
+ btn.setAttribute('data-vary', value || '1');
+ } else if (action === 'krea_profile') {
+ btn.setAttribute('data-krea-profile', value);
+ }
+ box.appendChild(btn);
+ }
+ }
+
+ function renderSkillChecks(skills, enabled) {
+ const box = $('sa_skills_box');
+ if (!box) {
+ return;
+ }
+ const on = new Set(enabled || []);
+ box.innerHTML = '';
+ for (const s of skills || []) {
+ const label = document.createElement('label');
+ label.className = 'sa-check';
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.setAttribute('data-skill', s.id);
+ input.checked = on.has(s.id) || (!enabled?.length && !!s.default);
+ input.addEventListener('change', () => {
+ state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
+ saveSettings();
+ });
+ label.appendChild(input);
+ label.appendChild(document.createTextNode(` ${s.title || s.id}`));
+ box.appendChild(label);
+ }
+ state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
+ }
+
+ function loadConfig(persona, done) {
+ if (typeof genericRequest !== 'function') {
+ done?.(null);
+ return;
+ }
+ genericRequest(
+ 'AssistentGetConfig',
+ { persona: persona || $('sa_persona')?.value || 'neutral' },
+ (data) => {
+ applyConfigPayload(data, { applyDefaults: true });
+ done?.(data);
+ },
+ 0,
+ () => done?.(null),
+ );
+ }
+
+ /** Larger param tags win (32b > 8b > 7b); instruct / qwen3 preferred over thinking/:latest. */
+ function chatModelSeniority(name) {
+ const n = String(name || '').toLowerCase();
+ let score = 0;
+ const m = n.match(/(?:^|[:\-/])(\d+)\s*b\b/);
+ if (m) {
+ score += Number(m[1]) * 1e6;
+ }
+ if (n.includes('instruct')) {
+ score += 5e4;
+ }
+ if (n.includes('qwen3')) {
+ score += 2e4;
+ }
+ if (n.includes('thinking') || n.endsWith(':latest')) {
+ score -= 1e4;
+ }
+ return score;
+ }
+
+ function pickSeniorChatModel(names) {
+ const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
+ if (!list.length) {
+ return '';
+ }
+ return [...list].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b))[0];
+ }
+
+ /**
+ * Resolve which chat tag to select / warm.
+ * Priority: api preferred (ollama-roles default_chat) → senior heuristic → LS (after one-shot junior upgrade).
+ */
+ function resolveChatModel(names, apiPreferred) {
+ const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
+ if (!list.length) {
+ return '';
+ }
+ const preferred = apiPreferred && list.includes(apiPreferred)
+ ? apiPreferred
+ : pickSeniorChatModel(list);
+ const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
+ if (!localStorage.getItem(LS_MODEL_SENIOR_MIG)) {
+ localStorage.setItem(LS_MODEL_SENIOR_MIG, '1');
+ if (preferred && (!ls || !list.includes(ls) || chatModelSeniority(ls) < chatModelSeniority(preferred))) {
+ state.preferredModel = preferred;
+ return preferred;
+ }
+ }
+ if (ls && list.includes(ls)) {
+ return ls;
+ }
+ return preferred || list[0];
+ }
+
+ function setModelOptions(models, { error, preferred } = {}) {
+ const sel = $('sa_model');
+ const sel2 = $('sa_settings_chat_model');
+ const apply = (target) => {
+ if (!target) {
+ return;
+ }
+ let names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
+ names = [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b));
+ target.innerHTML = '';
+ if (error) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
+ target.appendChild(opt);
+ target.disabled = true;
+ return;
+ }
+ target.disabled = false;
+ if (!names.length) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = 'No Ollama models — pull / Refresh';
+ target.appendChild(opt);
+ return;
+ }
+ for (const name of names) {
+ const opt = document.createElement('option');
+ opt.value = name;
+ opt.textContent = name;
+ target.appendChild(opt);
+ }
+ const pick = resolveChatModel(names, preferred);
+ if (pick) {
+ target.value = pick;
+ }
+ };
+ apply(sel);
+ apply(sel2);
+ }
+
+ function setEmbedModelOptions(models) {
+ const sel = $('sa_embed_model');
+ if (!sel) {
+ return;
+ }
+ const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
+ sel.innerHTML = '';
+ if (!names.length) {
+ const opt = document.createElement('option');
+ opt.value = state.preferredEmbed || 'nomic-embed-text';
+ opt.textContent = opt.value + ' (ожидается pull)';
+ sel.appendChild(opt);
+ return;
+ }
+ for (const name of names) {
+ const opt = document.createElement('option');
+ opt.value = name;
+ opt.textContent = name;
+ sel.appendChild(opt);
+ }
+ const prefer = state.preferredEmbed || localStorage.getItem(LS_EMBED) || state.config?.assistant?.embed_model;
+ if (prefer && names.includes(prefer)) {
+ sel.value = prefer;
+ } else if (prefer && !names.includes(prefer)) {
+ const opt = document.createElement('option');
+ opt.value = prefer;
+ opt.textContent = prefer;
+ sel.appendChild(opt);
+ sel.value = prefer;
+ }
+ }
+
+ function refreshModels() {
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ setStatus('Loading models…');
+ if (typeof genericRequest !== 'function') {
+ setStatus('SwarmUI API not ready');
+ setModelOptions([], { error: 'SwarmUI API not ready' });
+ return;
+ }
+ genericRequest(
+ 'AssistentListModels',
+ { baseUrl },
+ (data) => {
+ const models = data.models || [];
+ const memoryModels = data.memory_models || [];
+ const preferred = (data.preferred || '').trim();
+ setModelOptions(models, { preferred });
+ setEmbedModelOptions(memoryModels);
+ const pick = resolveChatModel(models, preferred);
+ if (pick && $('sa_model')) {
+ $('sa_model').value = pick;
+ if ($('sa_settings_chat_model')) {
+ $('sa_settings_chat_model').value = pick;
+ }
+ state.preferredModel = pick;
+ localStorage.setItem(LS_MODEL, pick);
+ }
+ setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
+ if (models.length) {
+ setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`);
+ } else {
+ setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
+ }
+ saveSettings();
+ },
+ 0,
+ (err) => {
+ const msg = String(err || 'Ollama unreachable');
+ setStatus(msg);
+ setModelOptions([], { error: msg });
+ setOllamaHealth('down', 'Ollama ✕', msg);
+ appendMessage('error', msg);
+ },
+ );
+ }
+
+ function refreshInventory(done, opts = {}) {
+ if (typeof genericRequest !== 'function') {
+ if (done) {
+ done();
+ }
+ return;
+ }
+ const rescan = !!opts.rescan;
+ genericRequest(
+ 'AssistentListInventory',
+ { rescan },
+ (data) => {
+ state.inventory = {
+ loras: data.loras || [],
+ checkpoints: data.checkpoints || [],
+ wildcards: data.wildcards || [],
+ has_civitai_key: !!data.has_civitai_key,
+ inventory_at: data.inventory_at || Math.floor(Date.now() / 1000),
+ rescanned: !!data.rescanned,
+ };
+ state.inventoryFetchedAt = Date.now();
+ const n = state.inventory.loras.length;
+ const ck = state.inventory.checkpoints.length;
+ setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? ' (rescanned)' : ''}`);
+ prefetchActiveModelCards();
+ if (state.view === 'cards') {
+ renderCardsList();
+ }
+ if (done) {
+ done(state.inventory);
+ }
+ },
+ 0,
+ (err) => {
+ console.warn('Assistent inventory', err);
+ if (done) {
+ done(null);
+ }
+ },
+ );
+ }
+
+ function refreshInventoryAsync(opts = {}) {
+ return new Promise((resolve) => refreshInventory(resolve, opts));
+ }
+
+ function memoryKindFilter() {
+ return $('sa_mem_kind')?.value || 'all';
+ }
+
+ function memoryScopeFilter() {
+ return $('sa_mem_scope')?.value || 'all';
+ }
+
+ function memorySearchFilter() {
+ return ($('sa_mem_search')?.value || '').trim().toLowerCase();
+ }
+
+ function renderMemoryKinds(kinds) {
+ const sel = $('sa_mem_kind');
+ if (!sel) {
+ return;
+ }
+ const cur = sel.value || 'all';
+ sel.innerHTML = '';
+ const all = document.createElement('option');
+ all.value = 'all';
+ all.textContent = 'Все типы';
+ sel.appendChild(all);
+ for (const kind of kinds || []) {
+ const opt = document.createElement('option');
+ opt.value = kind;
+ opt.textContent = kind;
+ sel.appendChild(opt);
+ }
+ if ([...sel.options].some((o) => o.value === cur)) {
+ sel.value = cur;
+ }
+ }
+
+ function filteredMemoryRows() {
+ const filter = memoryKindFilter();
+ const scope = memoryScopeFilter();
+ const q = memorySearchFilter();
+ const persona = $('sa_persona')?.value || 'neutral';
+ return (state.memoryRows || []).filter((m) => {
+ if (filter !== 'all' && m.kind !== filter) {
+ return false;
+ }
+ if (scope === 'shared' && m.scope !== 'shared') {
+ return false;
+ }
+ if (scope === 'personal' && !(m.scope === 'personal' && (m.persona === persona || !m.persona))) {
+ return false;
+ }
+ if (q) {
+ const hay = `${m.kind || ''} ${m.key || ''} ${m.text || ''}`.toLowerCase();
+ if (!hay.includes(q)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ function renderMemoryList() {
+ const root = $('sa_mem_list');
+ if (!root) {
+ return;
+ }
+ const rows = filteredMemoryRows();
+ root.innerHTML = '';
+ if (!rows.length) {
+ root.innerHTML = 'Крафт-память пуста — карточки, seed и патчи memory_upsert.
';
+ return;
+ }
+ for (const row of rows) {
+ const el = document.createElement('div');
+ el.className = 'sa-mem-row';
+ const bundled = row.source === 'bundled';
+ const when = row.updated ? formatChatWhen(row.updated * 1000) : '';
+ const scope = row.scope === 'personal' ? `персона ${row.persona || '—'}` : 'общая';
+ el.innerHTML = `${escapeHtml(row.kind || 'note')} ${escapeHtml(row.key || '')}
${escapeHtml(clipDebug(row.text, 220))}
${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}
`;
+ const forget = document.createElement('button');
+ forget.type = 'button';
+ forget.className = 'basic-button sa-mem-forget';
+ forget.textContent = '×';
+ if (bundled) {
+ forget.disabled = true;
+ forget.title = 'Bundled — вернётся при reseed, правь Config/_base/memory-seed/';
+ } else {
+ forget.title = 'Забыть';
+ forget.addEventListener('click', () => forgetMemory(row));
+ }
+ el.appendChild(forget);
+ root.appendChild(el);
+ }
+ }
+
+ function refreshMemoryList() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const list = $('sa_mem_list');
+ if (list && !state.memoryRows.length) {
+ list.innerHTML = 'Читаю память…
';
+ }
+ genericRequest(
+ 'AssistentListMemory',
+ { limit: 200 },
+ (data) => {
+ state.memoryRows = Array.isArray(data?.memories) ? data.memories : [];
+ renderMemoryKinds(data?.kinds || []);
+ renderMemoryList();
+ const foot = $('sa_mem_total');
+ if (foot) {
+ foot.textContent = `Всего: ${data?.total ?? state.memoryRows.length} · ${data?.embed_model || '—'}`;
+ }
+ },
+ 0,
+ (err) => {
+ if (list) {
+ list.innerHTML = `Память недоступна: ${escapeHtml(String(err || 'ошибка'))}
`;
+ }
+ },
+ );
+ }
+
+ function forgetMemory(row) {
+ if (!row?.kind || !row?.key || typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentForgetMemory',
+ {
+ kind: row.kind,
+ key: row.key,
+ source: row.source || '',
+ scope: row.scope || '',
+ persona: row.scope === 'personal' ? (row.persona || '') : '',
+ },
+ () => {
+ state.memoryRows = (state.memoryRows || []).filter((m) => !(m.kind === row.kind && m.key === row.key && m.source === row.source && m.persona === row.persona));
+ renderMemoryList();
+ setStatus(`Забыто: ${row.kind}/${row.key}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Не удалось забыть')),
+ );
+ }
+
+ function clearCraftMemory(opts = {}) {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const label = opts.label || 'крафт-память';
+ if (!window.confirm(`Очистить ${label}? Bundled seed останется.`)) {
+ return;
+ }
+ const body = {
+ scope: opts.scope || '',
+ kind: opts.kind || '',
+ persona: opts.persona || '',
+ };
+ genericRequest(
+ 'AssistentClearMemory',
+ body,
+ (data) => {
+ setStatus(`Удалено крафт-записей: ${data?.deleted ?? 0}`);
+ refreshMemoryList();
+ },
+ 0,
+ (err) => setStatus(String(err || 'Очистка не удалась')),
+ );
+ }
+
+ function setSettingsTab(id) {
+ state.settingsTab = id || 'behavior';
+ document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
+ const on = btn.getAttribute('data-stab') === state.settingsTab;
+ btn.classList.toggle('sa-stab-active', on);
+ btn.setAttribute('aria-selected', on ? 'true' : 'false');
+ });
+ document.querySelectorAll('#sa_settings .sa-spane').forEach((pane) => {
+ pane.hidden = pane.getAttribute('data-spane') !== state.settingsTab;
+ });
+ if (state.settingsTab === 'craft') {
+ refreshMemoryList();
+ refreshWantedQueue();
+ }
+ if (state.settingsTab === 'user') {
+ refreshUserPrefs();
+ }
+ if (state.settingsTab === 'personas') {
+ renderPersonaSettingsList();
+ }
+ if (state.settingsTab === 'models') {
+ syncSettingsHealthLine();
+ const m = $('sa_model')?.value;
+ if (m && $('sa_settings_chat_model')) {
+ $('sa_settings_chat_model').value = m;
+ }
+ }
+ if (state.settingsTab === 'more') {
+ fillKnobsFromConfig(state.config);
+ }
+ }
+
+ function fillKnobsFromConfig(data) {
+ const asst = data?.assistant || state.config?.assistant || {};
+ const exact = data?.exact || state.config?.exact || state.exact || {};
+ const setNum = (id, v) => {
+ const el = $(id);
+ if (el && v != null && Number.isFinite(Number(v))) {
+ el.value = String(v);
+ }
+ };
+ setNum('sa_num_ctx', asst.num_ctx);
+ setNum('sa_history_keep', asst.history_keep_turns);
+ setNum('sa_memory_top_k', asst.memory_top_k);
+ const w = asst.user_prefs_weight != null ? Number(asst.user_prefs_weight) : 1;
+ const weightEl = $('sa_user_prefs_weight');
+ if (weightEl) {
+ weightEl.value = String(Math.max(0, Math.min(1.5, w)));
+ const lab = $('sa_user_prefs_weight_val');
+ if (lab) {
+ lab.textContent = Number(weightEl.value).toFixed(1);
+ }
+ }
+ const turbo = exact.profiles?.turbo || {};
+ const raw = exact.profiles?.raw || {};
+ setNum('sa_exact_turbo_steps', turbo.steps);
+ setNum('sa_exact_turbo_cfg', turbo.cfg);
+ setNum('sa_exact_turbo_sigma', turbo.sigma_shift);
+ setNum('sa_exact_raw_steps', raw.steps);
+ setNum('sa_exact_raw_cfg', raw.cfg);
+ setNum('sa_exact_raw_sigma', raw.sigma_shift);
+ }
+
+ function saveKnobs() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const num = (id) => {
+ const v = parseFloat($(id)?.value);
+ return Number.isFinite(v) ? v : null;
+ };
+ const assistant = {
+ num_ctx: num('sa_num_ctx'),
+ history_keep_turns: num('sa_history_keep'),
+ memory_top_k: num('sa_memory_top_k'),
+ user_prefs_weight: num('sa_user_prefs_weight'),
+ };
+ Object.keys(assistant).forEach((k) => {
+ if (assistant[k] == null) {
+ delete assistant[k];
+ }
+ });
+ const exact = {
+ profiles: {
+ turbo: {
+ steps: num('sa_exact_turbo_steps'),
+ cfg: num('sa_exact_turbo_cfg'),
+ sigma_shift: num('sa_exact_turbo_sigma'),
+ },
+ raw: {
+ steps: num('sa_exact_raw_steps'),
+ cfg: num('sa_exact_raw_cfg'),
+ sigma_shift: num('sa_exact_raw_sigma'),
+ },
+ },
+ };
+ genericRequest(
+ 'AssistentSaveKnobs',
+ { assistant, exact },
+ (data) => {
+ if (data?.assistant || data?.exact) {
+ applyConfigPayload({
+ ...state.config,
+ assistant: data.assistant || state.config?.assistant,
+ exact: data.exact || state.config?.exact,
+ });
+ }
+ setStatus('Knobs сохранены в overlay');
+ },
+ 0,
+ (err) => setStatus(String(err || 'Не удалось сохранить knobs')),
+ );
+ }
+
+ function syncSettingsHealthLine() {
+ const line = $('sa_settings_health_line');
+ const badge = $('sa_ollama_health');
+ if (line && badge) {
+ line.textContent = badge.textContent || 'Ollama · …';
+ line.className = 'sa-settings-health ' + (badge.className || '').replace('sa-health', '').trim();
+ }
+ }
+
+ function personaSourceLabel(source) {
+ if (source === 'overlay') {
+ return 'моя';
+ }
+ if (source === 'overlay+bundled') {
+ return 'встроено+правка';
+ }
+ return 'встроено';
+ }
+
+ function renderPersonaSettingsList() {
+ const root = $('sa_persona_list');
+ if (!root) {
+ return;
+ }
+ const list = state.personas || state.config?.personas || [];
+ const cur = state.settingsPersonaId || $('sa_persona')?.value || list[0]?.id;
+ state.settingsPersonaId = cur;
+ root.innerHTML = '';
+ for (const p of list) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-persona-item' + (p.id === cur ? ' sa-persona-item-active' : '');
+ const accent = p.accent || 'currentColor';
+ btn.innerHTML = `${escapeHtml(p.title || p.id)}
${escapeHtml(personaSourceLabel(p.source))}
`;
+ btn.addEventListener('click', () => {
+ state.settingsPersonaId = p.id;
+ renderPersonaSettingsList();
+ loadPersonaPreview(p.id);
+ });
+ root.appendChild(btn);
+ }
+ syncPersonaPanelActions();
+ if (cur) {
+ loadPersonaPreview(cur);
+ }
+ }
+
+ function syncPersonaPanelActions() {
+ const id = state.settingsPersonaId;
+ const p = (state.personas || []).find((x) => x.id === id);
+ const canDelete = p && (p.source === 'overlay' || p.source === 'overlay+bundled');
+ const del = $('sa_btn_persona_delete_panel');
+ if (del) {
+ del.disabled = !canDelete;
+ }
+ }
+
+ function loadPersonaPreview(id) {
+ const box = $('sa_persona_preview');
+ if (!box || typeof genericRequest !== 'function') {
+ return;
+ }
+ box.innerHTML = 'Загрузка…
';
+ genericRequest(
+ 'AssistentGetPersonaShelves',
+ { persona: id },
+ (data) => {
+ const summary = data?.identity_summary || '';
+ const src = data?.source || '';
+ box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
+ syncPersonaPanelActions();
+ },
+ 0,
+ (err) => {
+ box.innerHTML = `${escapeHtml(String(err || 'ошибка'))}
`;
+ },
+ );
+ }
+
+ function exportSelectedPersona() {
+ const id = state.settingsPersonaId || $('sa_persona')?.value;
+ if (!id || typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentExportPersona',
+ { persona: id },
+ (data) => {
+ const pack = data?.pack;
+ if (!pack) {
+ setStatus('Пустой экспорт');
+ return;
+ }
+ const blob = new Blob([JSON.stringify(pack, null, 2)], { type: 'application/json' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `${pack.id || id}.assistent-persona.json`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ setStatus(`Экспорт: ${a.download}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Экспорт не удался')),
+ );
+ }
+
+ function importPersonaFile(file) {
+ if (!file || typeof genericRequest !== 'function') {
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ let pack;
+ try {
+ pack = JSON.parse(String(reader.result || ''));
+ } catch (e) {
+ setStatus('Невалидный JSON');
+ return;
+ }
+ let newId = pack?.id || '';
+ if ((state.personas || []).some((p) => p.id === newId && (p.source === 'bundled' || p.source === 'overlay+bundled'))) {
+ newId = window.prompt('Id занят bundled — новый id:', `${newId}_import`) || '';
+ }
+ genericRequest(
+ 'AssistentImportPersona',
+ { pack, new_id: newId || null, overwrite: false },
+ (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ renderPersonaOptions(data.personas, data.persona?.id);
+ }
+ state.settingsPersonaId = data?.persona?.id || newId;
+ renderPersonaSettingsList();
+ setStatus(`Импорт: ${data?.persona?.id || newId}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Импорт не удался')),
+ );
+ };
+ reader.readAsText(file);
+ }
+
+ function cloneSelectedPersona() {
+ const from = state.settingsPersonaId || $('sa_persona')?.value;
+ if (!from) {
+ return;
+ }
+ const to = window.prompt('Новый id личности:', `${from}_copy`);
+ if (!to) {
+ return;
+ }
+ genericRequest(
+ 'AssistentClonePersona',
+ { from, to, title: to },
+ (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ renderPersonaOptions(data.personas, to);
+ }
+ state.settingsPersonaId = to;
+ renderPersonaSettingsList();
+ setStatus(`Клон: ${to}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Клон не удался')),
+ );
+ }
+
+ function deleteSelectedOverlayPersona() {
+ const id = state.settingsPersonaId;
+ const p = (state.personas || []).find((x) => x.id === id);
+ if (!p || (p.source !== 'overlay' && p.source !== 'overlay+bundled')) {
+ setStatus('Можно удалить только overlay');
+ return;
+ }
+ if (!window.confirm(`Удалить overlay-личность «${id}»?`)) {
+ return;
+ }
+ genericRequest(
+ 'AssistentDeletePersona',
+ { persona: id },
+ (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ renderPersonaOptions(data.personas, data.default_persona);
+ }
+ state.settingsPersonaId = data?.default_persona || null;
+ renderPersonaSettingsList();
+ setStatus(`Удалено: ${id}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Удаление не удалось')),
+ );
+ }
+
+ function refreshUserPrefs() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const persona = $('sa_persona')?.value || 'neutral';
+ genericRequest(
+ 'AssistentListUserPrefs',
+ { persona, limit: 200 },
+ (data) => {
+ state.userPrefs = Array.isArray(data?.prefs) ? data.prefs : [];
+ renderUserPrefsLists();
+ },
+ 0,
+ (err) => setStatus(String(err || 'User prefs недоступны')),
+ );
+ }
+
+ function renderUserPrefsLists() {
+ const persona = $('sa_persona')?.value || 'neutral';
+ const global = (state.userPrefs || []).filter((p) => p.scope === 'global');
+ const personal = (state.userPrefs || []).filter((p) => p.scope === 'persona' && (p.persona_id === persona || p.persona === persona));
+ const fill = (rootId, rows) => {
+ const root = $(rootId);
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '';
+ if (!rows.length) {
+ root.innerHTML = 'Пусто
';
+ return;
+ }
+ for (const row of rows) {
+ const el = document.createElement('div');
+ el.className = 'sa-mem-row';
+ const pin = row.pinned ? ' ★' : '';
+ el.innerHTML = `${escapeHtml(row.key || '')}${pin}
${escapeHtml(clipDebug(row.text, 200))}
`;
+ el.querySelector('.sa-mem-row-body')?.addEventListener('click', () => editUserPref(row));
+ el.querySelector('.sa-mem-row-body')?.setAttribute('title', 'Клик — редактировать');
+ const pinBtn = document.createElement('button');
+ pinBtn.type = 'button';
+ pinBtn.className = 'basic-button sa-mem-forget';
+ pinBtn.textContent = row.pinned ? '★' : '☆';
+ pinBtn.title = row.pinned ? 'Unpin' : 'Pin';
+ pinBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleUserPrefPin(row);
+ });
+ el.appendChild(pinBtn);
+ const forget = document.createElement('button');
+ forget.type = 'button';
+ forget.className = 'basic-button sa-mem-forget';
+ forget.textContent = '×';
+ forget.title = 'Забыть';
+ forget.addEventListener('click', (e) => {
+ e.stopPropagation();
+ forgetUserPref(row);
+ });
+ el.appendChild(forget);
+ root.appendChild(el);
+ }
+ };
+ fill('sa_prefs_global', global);
+ fill('sa_prefs_persona', personal);
+ }
+
+ function editUserPref(row) {
+ const text = window.prompt('Текст факта:', row.text || '');
+ if (text == null || !String(text).trim()) {
+ return;
+ }
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ {
+ key: row.key,
+ text: String(text).trim(),
+ scope: row.scope || 'global',
+ persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
+ source: 'user',
+ pinned: !!row.pinned,
+ },
+ () => refreshUserPrefs(),
+ 0,
+ (err) => setStatus(String(err || 'Не удалось сохранить')),
+ );
+ }
+
+ function toggleUserPrefPin(row) {
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ {
+ key: row.key,
+ text: row.text,
+ scope: row.scope || 'global',
+ persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
+ source: row.source || 'user',
+ pinned: !row.pinned,
+ },
+ () => refreshUserPrefs(),
+ 0,
+ (err) => setStatus(String(err || 'Не удалось pin')),
+ );
+ }
+
+ function addUserPref(scope) {
+ const key = window.prompt('Ключ (stable-id):', scope === 'global' ? 'prefer' : 'tone');
+ if (!key) {
+ return;
+ }
+ const text = window.prompt('Текст факта:', '');
+ if (!text) {
+ return;
+ }
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ {
+ key: key.trim(),
+ text: text.trim(),
+ scope,
+ persona: $('sa_persona')?.value || 'neutral',
+ source: 'user',
+ pinned: false,
+ },
+ () => {
+ refreshUserPrefs();
+ setStatus('Сохранено');
+ },
+ 0,
+ (err) => setStatus(String(err || 'Не удалось сохранить')),
+ );
+ }
+
+ function forgetUserPref(row) {
+ genericRequest(
+ 'AssistentForgetUserPref',
+ {
+ key: row.key,
+ scope: row.scope || 'global',
+ persona: row.persona_id || row.persona || $('sa_persona')?.value,
+ },
+ () => refreshUserPrefs(),
+ 0,
+ (err) => setStatus(String(err || 'Не удалось забыть')),
+ );
+ }
+
+ function clearUserPrefs(scope) {
+ const labels = { global: 'общие prefs', persona: 'prefs этой личности', all: 'все prefs о пользователе' };
+ if (!window.confirm(`Очистить ${labels[scope] || scope}?`)) {
+ return;
+ }
+ genericRequest(
+ 'AssistentClearUserPrefs',
+ { scope, persona: $('sa_persona')?.value || 'neutral' },
+ (data) => {
+ setStatus(`Удалено: ${data?.deleted ?? 0}`);
+ refreshUserPrefs();
+ },
+ 0,
+ (err) => setStatus(String(err || 'Очистка не удалась')),
+ );
+ }
+
+ function resetUiState() {
+ if (!window.confirm('Сбросить UI-state (local + disk)? Настройки Ollama и prefs останутся.')) {
+ return;
+ }
+ const keys = Object.keys(localStorage).filter((k) => k.startsWith('swarm_assistent_'));
+ for (const k of keys) {
+ if (k === LS_TASTE) {
+ continue;
+ }
+ localStorage.removeItem(k);
+ }
+ diskPersist()?.saveUiState?.({});
+ setStatus('UI-state сброшен — обнови страницу');
+ }
+
+ function modelKeyLeaf(name) {
+ return String(name || '')
+ .replace(/\\/g, '/')
+ .split('/')
+ .pop()
+ .replace(/\.(safetensors|ckpt|pt|pth|gguf|bin)$/i, '')
+ .trim()
+ .toLowerCase();
+ }
+
+ function refreshWantedQueue() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentListWanted',
+ {},
+ (data) => {
+ const items = Array.isArray(data?.items) ? data.items : [];
+ state.wanted = { count: data?.count ?? items.length, items };
+ const keys = new Set();
+ for (const item of items) {
+ const leaf = modelKeyLeaf(item?.title);
+ if (leaf) {
+ keys.add(leaf);
+ }
+ if (item?.version_id) {
+ keys.add(`v${item.version_id}`);
+ }
+ }
+ state.wantedKeys = keys;
+ const el = $('sa_mem_wanted');
+ if (el) {
+ el.textContent = state.wanted.count
+ ? `Очередь wanted: ${state.wanted.count} (скачается на следующем up)`
+ : 'Очередь wanted: пусто';
+ el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join('\n');
+ }
+ if (state.view === 'cards') {
+ renderCardsList();
+ }
+ },
+ 0,
+ () => {
+ const el = $('sa_mem_wanted');
+ if (el) {
+ el.textContent = 'Очередь wanted: —';
+ }
+ },
+ );
+ }
+
+ function isWantedModel(row) {
+ const keys = state.wantedKeys;
+ if (!keys || !keys.size) {
+ return false;
+ }
+ for (const candidate of [row?.name, row?.title]) {
+ const leaf = modelKeyLeaf(candidate);
+ if (leaf && keys.has(leaf)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function setOllamaHealth(level, text, title) {
+ state.ollamaHealth = level;
+ const el = $('sa_ollama_health');
+ if (!el) {
+ return;
+ }
+ el.hidden = false;
+ el.textContent = text;
+ el.title = title || text;
+ el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
+ el.classList.add(`sa-health-${level}`);
+ syncSettingsHealthLine();
+ }
+
+ function probeOllamaHealth() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ genericRequest(
+ 'AssistentListModels',
+ { baseUrl },
+ (data) => {
+ if (data?.error) {
+ setOllamaHealth('down', 'Ollama ✕', String(data.error));
+ return;
+ }
+ const chat = (data.models || []).length;
+ const mem = (data.memory_models || []).length;
+ if (!chat) {
+ setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
+ return;
+ }
+ setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
+ },
+ 0,
+ (err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`),
+ );
+ }
+
+ function setCardStatus(msg) {
+ const el = $('sa_card_status');
+ if (el) {
+ el.textContent = msg || '';
+ }
+ }
+
+ function setView(view) {
+ if (view === 'cards') {
+ state.view = 'cards';
+ } else if (view === 'settings') {
+ state.view = 'settings';
+ } else {
+ state.view = 'chat';
+ }
+ const chat = $('sa_view_chat');
+ const cards = $('sa_view_cards');
+ const settings = $('sa_view_settings');
+ if (chat) {
+ chat.hidden = state.view !== 'chat';
+ }
+ if (cards) {
+ cards.hidden = state.view !== 'cards';
+ }
+ if (settings) {
+ settings.hidden = state.view !== 'settings';
+ }
+ $('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
+ $('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
+ $('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
+ $('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
+ saveSettings();
+ if (state.view === 'cards') {
+ renderCardsList();
+ } else if (state.view === 'settings') {
+ setSettingsTab(state.settingsTab || 'behavior');
+ } else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
+ // Back in the chat — bring the model home (Krea may have evicted it).
+ warmLlm({ force: true });
+ }
+ }
+
+ function openSettings(tab) {
+ if (tab) {
+ state.settingsTab = tab;
+ }
+ setView('settings');
+ }
+
+ function closeSettings() {
+ setView('chat');
+ }
+
+ function refreshPersonas() {
+ loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => {
+ if (data?.personas) {
+ state.personas = data.personas;
+ }
+ });
+ }
+
+ function prefetchCard(kind, name) {
+ return new Promise((resolve) => {
+ if (!kind || !name || typeof genericRequest !== 'function') {
+ resolve(null);
+ return;
+ }
+ const key = `${kind}:${name}`;
+ genericRequest(
+ 'AssistentGetCard',
+ { kind, name },
+ (data) => {
+ if (data?.card) {
+ state.modelCards[key] = data.card;
+ }
+ resolve(data?.card || null);
+ },
+ 0,
+ () => resolve(null),
+ );
+ });
+ }
+
+ async function prefetchActiveModelCards() {
+ const keys = [];
+ const seen = new Set();
+ const add = (kind, name) => {
+ if (!kind || !name) {
+ return;
+ }
+ const key = `${kind}:${name}`;
+ if (seen.has(key)) {
+ return;
+ }
+ seen.add(key);
+ keys.push({ kind, name });
+ };
+ try {
+ const ck = resolveCurrentCheckpoint();
+ if (ck?.name) {
+ add('checkpoint', ck.name);
+ }
+ } catch (e) { /* ignore */ }
+ try {
+ if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
+ for (const l of loraHelper.selected) {
+ add('lora', l?.name || l);
+ }
+ }
+ } catch (e) { /* ignore */ }
+ for (const l of state.inventory?.loras || []) {
+ if (l?.has_card) {
+ add('lora', l.name);
+ }
+ if (keys.length >= 14) {
+ break;
+ }
+ }
+ for (const c of state.inventory?.checkpoints || []) {
+ if (c?.has_card) {
+ add('checkpoint', c.name);
+ }
+ if (keys.length >= 16) {
+ break;
+ }
+ }
+ await Promise.all(keys.map((k) => prefetchCard(k.kind, k.name)));
+ }
+
+ function cardsCatalog() {
+ const kind = $('sa_cards_kind')?.value || 'all';
+ const inv = state.inventory || {};
+ const rows = [];
+ if (kind === 'all' || kind === 'checkpoint') {
+ for (const c of inv.checkpoints || []) {
+ rows.push({
+ kind: 'checkpoint',
+ name: c.name,
+ title: c.title || c.name,
+ has_card: !!c.has_card,
+ hash: c.hash || '',
+ preview_url: c.preview_url || null,
+ has_sidecar: !!c.has_sidecar,
+ });
+ }
+ }
+ if (kind === 'all' || kind === 'lora') {
+ for (const l of inv.loras || []) {
+ rows.push({
+ kind: 'lora',
+ name: l.name,
+ title: l.title || l.name,
+ has_card: !!l.has_card,
+ trigger: l.trigger_phrase,
+ hash: l.hash || '',
+ preview_url: l.preview_url || null,
+ has_sidecar: !!l.has_sidecar,
+ });
+ }
+ }
+ return rows;
+ }
+
+ function renderCardsList() {
+ const root = $('sa_cards_list');
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '';
+ const rows = cardsCatalog();
+ if (!rows.length) {
+ root.innerHTML = 'Inventory пуст — Обновить.
';
+ 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
+ ? ` `
+ : '
';
+ const metaBits = [];
+ metaBits.push(row.has_card ? 'card ✓' : 'нет card');
+ if (row.has_sidecar) {
+ metaBits.push('sidecar');
+ }
+ if (isWantedModel(row)) {
+ metaBits.push('⏳ wanted');
+ btn.classList.add('sa-card-row-wanted');
+ }
+ if (row.trigger) {
+ metaBits.push(String(row.trigger).slice(0, 40));
+ }
+ btn.innerHTML = `${thumb}${escapeHtml(row.kind)}
${escapeHtml(row.title || row.name)}
${escapeHtml(metaBits.join(' · '))}
↗ `;
+ btn.addEventListener('click', (e) => {
+ if (e.target?.closest?.('[data-chat]')) {
+ e.preventDefault();
+ e.stopPropagation();
+ sendCardToChat(row);
+ return;
+ }
+ selectCardModel(row);
+ });
+ root.appendChild(btn);
+ }
+ }
+
+ function sendCardToChat(row) {
+ if (!row?.name) {
+ return;
+ }
+ selectCardModel(row);
+ setView('chat');
+ const kind = row.kind === 'checkpoint' ? 'checkpoint' : 'LoRA';
+ const triggers = row.trigger ? ` Triggers: ${row.trigger}.` : '';
+ if ($('sa_input')) {
+ $('sa_input').value = `Используй ${kind} «${row.name}».${triggers} Учти карточку/triggers и предложи патч.`;
+ $('sa_input').focus();
+ }
+ setStatus(`В чат → ${row.name}`);
+ }
+
+ function wireCardForm() {
+ const sync = () => {
+ if ($('sa_card_show_json')?.checked) {
+ syncCardJsonFromForm();
+ }
+ };
+ ['sa_card_triggers', 'sa_card_weight', 'sa_card_when', 'sa_card_avoid', 'sa_card_hint', 'sa_card_notes', 'sa_card_url']
+ .forEach((id) => $(id)?.addEventListener('change', sync));
+ $('sa_card_show_json')?.addEventListener('change', () => {
+ const on = !!$('sa_card_show_json')?.checked;
+ const ta = $('sa_card_json');
+ if (ta) {
+ ta.hidden = !on;
+ if (on) {
+ syncCardJsonFromForm();
+ }
+ }
+ });
+ $('sa_card_json')?.addEventListener('change', () => {
+ if ($('sa_card_show_json')?.checked) {
+ applyCardToForm(readCardDraft() || {});
+ }
+ });
+ }
+
+ function applyCardToForm(card) {
+ card = card || {};
+ const triggers = Array.isArray(card.triggers) ? card.triggers.join(', ') : (card.triggers || '');
+ if ($('sa_card_triggers')) {
+ $('sa_card_triggers').value = triggers;
+ }
+ if ($('sa_card_weight')) {
+ $('sa_card_weight').value = card.weight != null ? card.weight : (state.cardsSelection?.kind === 'lora' ? 0.8 : 1);
+ }
+ if ($('sa_card_when')) {
+ $('sa_card_when').value = card.when || '';
+ }
+ if ($('sa_card_avoid')) {
+ $('sa_card_avoid').value = card.avoid || '';
+ }
+ if ($('sa_card_hint')) {
+ $('sa_card_hint').value = card.prompt_hint || '';
+ }
+ if ($('sa_card_notes')) {
+ $('sa_card_notes').value = card.notes || '';
+ }
+ if ($('sa_card_url')) {
+ $('sa_card_url').value = card.civitai_url || '';
+ }
+ if ($('sa_card_json')) {
+ $('sa_card_json').value = JSON.stringify(card, null, 2);
+ }
+ }
+
+ function syncCardJsonFromForm() {
+ const sel = state.cardsSelection || {};
+ let base = {};
+ try {
+ base = JSON.parse($('sa_card_json')?.value || '{}');
+ } catch (e) {
+ base = {};
+ }
+ const triggers = String($('sa_card_triggers')?.value || '')
+ .split(/[,;]/)
+ .map((s) => s.trim())
+ .filter(Boolean);
+ const card = {
+ ...base,
+ kind: sel.kind || base.kind || 'lora',
+ name: sel.name || base.name || '',
+ triggers,
+ weight: parseFloat($('sa_card_weight')?.value || '0.8') || 0.8,
+ when: $('sa_card_when')?.value || '',
+ avoid: $('sa_card_avoid')?.value || '',
+ prompt_hint: $('sa_card_hint')?.value || '',
+ notes: $('sa_card_notes')?.value || '',
+ civitai_url: $('sa_card_url')?.value || '',
+ version_id: base.version_id != null ? base.version_id : null,
+ };
+ if ($('sa_card_json')) {
+ $('sa_card_json').value = JSON.stringify(card, null, 2);
+ }
+ return card;
+ }
+
+ function renderCardPreviews(urls) {
+ const root = $('sa_card_previews');
+ if (!root) {
+ return;
+ }
+ const list = (urls || []).filter(Boolean).slice(0, 6);
+ root.innerHTML = '';
+ if (!list.length) {
+ root.hidden = true;
+ return;
+ }
+ root.hidden = false;
+ for (const url of list) {
+ const img = document.createElement('img');
+ img.className = 'sa-card-thumb';
+ img.src = url;
+ img.alt = 'preview';
+ img.title = 'Клик — на вкладку Refs';
+ img.addEventListener('click', () => {
+ setBoardTab('refs');
+ addRefFromUrl(url);
+ setStatus('Превью → Refs');
+ });
+ root.appendChild(img);
+ }
+ }
+
+ function mergeCivitaiIntoCard(card, data) {
+ const out = { ...(card || {}) };
+ const civ = data?.civitai;
+ if (!out.triggers?.length && data?.trigger_phrase) {
+ out.triggers = [data.trigger_phrase];
+ }
+ if (civ) {
+ const trained = civ.trainedWords || civ.trained_words;
+ if ((!out.triggers || !out.triggers.length) && Array.isArray(trained) && trained.length) {
+ out.triggers = trained.slice(0, 12);
+ }
+ if (!out.civitai_url) {
+ const mid = civ.modelId || civ.model?.id || civ.model?.modelId;
+ const vid = civ.id || data.version_id;
+ if (mid && vid) {
+ out.civitai_url = `https://civitai.red/models/${mid}?modelVersionId=${vid}`;
+ } else if (vid) {
+ out.civitai_url = `https://civitai.red/models/0?modelVersionId=${vid}`;
+ }
+ }
+ if (out.version_id == null && (civ.id || data.version_id)) {
+ out.version_id = civ.id || data.version_id;
+ }
+ if (!out.notes && civ.description) {
+ out.notes = String(civ.description).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400);
+ }
+ }
+ if (out.version_id == null && data?.version_id) {
+ out.version_id = data.version_id;
+ }
+ return out;
+ }
+
+ function formatMetaStatus(data) {
+ if (!data) {
+ return 'Нет ответа';
+ }
+ if (data.error) {
+ return String(data.error);
+ }
+ const parts = [];
+ if (data.has_sidecar) {
+ parts.push(`Сидикарь ✓ · version ${data.version_id || '?'}`);
+ } else if (data.fetched) {
+ parts.push(`Civitai ✓ · version ${data.version_id || '?'}`);
+ } else {
+ parts.push('Сидикаря нет');
+ }
+ const n = (data.example_urls || data.preview_urls || []).length;
+ if (n) {
+ parts.push(`${n} кадр${n === 1 ? '' : 'а'}`);
+ }
+ if (data.has_card) {
+ parts.push('карточка Assistent ✓');
+ } else {
+ parts.push('карточки Assistent нет');
+ }
+ if (data.fetch_error) {
+ parts.push(String(data.fetch_error));
+ }
+ return parts.join(' · ');
+ }
+
+ function applyCardMetaResponse(row, data, { preserveUser } = {}) {
+ let card = data.card || {
+ kind: row.kind,
+ name: row.name,
+ triggers: data.trigger_phrase ? [data.trigger_phrase] : [],
+ weight: row.kind === 'lora' ? 0.8 : 1,
+ when: '',
+ avoid: '',
+ prompt_hint: '',
+ notes: '',
+ civitai_url: '',
+ version_id: data.version_id || null,
+ };
+ if (preserveUser) {
+ const current = syncCardJsonFromForm();
+ card = {
+ ...mergeCivitaiIntoCard(card, data),
+ when: current.when || card.when || '',
+ avoid: current.avoid || card.avoid || '',
+ prompt_hint: current.prompt_hint || card.prompt_hint || '',
+ notes: current.notes || card.notes || '',
+ };
+ } else {
+ card = mergeCivitaiIntoCard(card, data);
+ }
+ applyCardToForm(card);
+ state.modelCards[`${row.kind}:${row.name}`] = card;
+ const urls = [
+ ...(data.preview_urls || []),
+ ...(data.example_urls || []),
+ ];
+ renderCardPreviews(urls);
+ const badge = $('sa_card_badge');
+ if (badge) {
+ badge.hidden = false;
+ badge.textContent = data.has_card ? 'card ✓' : (data.has_sidecar || data.fetched ? 'meta ✓' : 'нет меты');
+ }
+ setCardStatus(formatMetaStatus(data));
+ }
+
+ function selectCardModel(row) {
+ state.cardsSelection = row;
+ renderCardsList();
+ if ($('sa_card_title')) {
+ $('sa_card_title').textContent = row.title || row.name;
+ }
+ const badge = $('sa_card_badge');
+ if (badge) {
+ badge.hidden = false;
+ badge.textContent = '…';
+ }
+ setCardStatus('Читаю локальную мету…');
+ genericRequest(
+ 'AssistentGetCardMeta',
+ { kind: row.kind, name: row.name, fetch: false },
+ (data) => applyCardMetaResponse(row, data || {}),
+ 0,
+ (err) => setCardStatus(String(err || 'Ошибка загрузки')),
+ );
+ }
+
+ function fetchCardMetaLive() {
+ const row = state.cardsSelection;
+ if (!row) {
+ setCardStatus('Выбери модель');
+ return;
+ }
+ setCardStatus('Сидикаря нет · ищу по SHA…');
+ genericRequest(
+ 'AssistentGetCardMeta',
+ { kind: row.kind, name: row.name, fetch: true },
+ (data) => applyCardMetaResponse(row, data || {}, { preserveUser: true }),
+ 0,
+ (err) => setCardStatus(String(err || 'Civitai: ошибка запроса')),
+ );
+ }
+
+ async function addRefFromUrl(url) {
+ if (!url) {
+ return;
+ }
+ addRefSlot({ src: url, select: false });
+ }
+
+ function readCardDraft() {
+ const fromForm = syncCardJsonFromForm();
+ if ($('sa_card_show_json')?.checked) {
+ const raw = $('sa_card_json')?.value || '';
+ try {
+ return JSON.parse(raw);
+ } catch (e) {
+ setCardStatus('Невалидный JSON');
+ return null;
+ }
+ }
+ return fromForm;
+ }
+
+ function saveCurrentCard({ enqueue } = {}) {
+ const sel = state.cardsSelection;
+ if (!sel) {
+ setCardStatus('Выбери модель');
+ return;
+ }
+ const card = readCardDraft();
+ if (!card) {
+ return;
+ }
+ card.kind = card.kind || sel.kind;
+ card.name = card.name || sel.name;
+ setCardStatus('Сохраняю…');
+ genericRequest(
+ 'AssistentSaveCard',
+ { kind: sel.kind, name: sel.name, card, enqueue_wanted: !!enqueue },
+ (data) => {
+ if (data.error) {
+ setCardStatus(data.error);
+ return;
+ }
+ state.modelCards[`${sel.kind}:${sel.name}`] = card;
+ setCardStatus(data.installed
+ ? `Карточка Assistent сохранена · ${data.path}`
+ : `Черновик + wanted · ${data.path}`);
+ refreshInventory(() => renderCardsList());
+ if (enqueue || !data.installed) {
+ refreshWantedQueue();
+ }
+ },
+ 0,
+ (err) => setCardStatus(String(err || 'Ошибка сохранения')),
+ );
+ }
+
+ function enqueueWantedOnly() {
+ const sel = state.cardsSelection;
+ const card = readCardDraft() || {};
+ if (!sel && !card.civitai_url) {
+ setCardStatus('Нужна модель или civitai_url');
+ return;
+ }
+ genericRequest(
+ 'AssistentEnqueueWanted',
+ {
+ kind: (card.kind || sel?.kind || 'lora'),
+ url: card.civitai_url || '',
+ version_id: card.version_id || 0,
+ title: card.name || sel?.name || '',
+ card,
+ },
+ (data) => {
+ setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
+ refreshWantedQueue();
+ },
+ 0,
+ (err) => setCardStatus(String(err || 'Ошибка enqueue')),
+ );
+ }
+
+ function shortLoraName(name) {
+ const s = String(name || '');
+ const base = s.split(/[/\\]/).pop() || s;
+ return base.replace(/\.safetensors$/i, '').slice(0, 28);
+ }
+
+ function renderLoraChips() {
+ const root = $('sa_lora_chips');
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '';
+ let selected = [];
+ try {
+ if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
+ selected = loraHelper.selected.map((l) => ({
+ name: l.name || l,
+ weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
+ }));
+ }
+ } catch (e) { /* ignore */ }
+ for (const l of selected) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-lora-chip';
+ btn.title = `${l.name} ×${l.weight} — клик снять`;
+ btn.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
+ btn.addEventListener('click', () => {
+ try {
+ if (typeof loraHelper !== 'undefined' && typeof loraHelper.removeLora === 'function') {
+ loraHelper.removeLora(l.name);
+ } else if (loraHelper?.selected) {
+ loraHelper.selected = loraHelper.selected.filter((x) => (x.name || x) !== l.name);
+ if (typeof loraHelper.rebuildUI === 'function') {
+ loraHelper.rebuildUI();
+ }
+ }
+ } catch (e) { /* ignore */ }
+ renderLoraChips();
+ });
+ root.appendChild(btn);
+ }
+ const add = document.createElement('button');
+ add.type = 'button';
+ add.className = 'sa-lora-chip sa-lora-add';
+ add.textContent = '+ LoRA';
+ add.title = 'Добавить из inventory';
+ add.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openLoraPicker(add);
+ });
+ root.appendChild(add);
+ }
+
+ function openLoraPicker(anchor) {
+ document.querySelectorAll('.sa-lora-picker').forEach((n) => n.remove());
+ const picker = document.createElement('div');
+ picker.className = 'sa-lora-picker';
+ const inv = (state.inventory?.loras || []).slice().sort((a, b) => (b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0));
+ const filter = document.createElement('input');
+ filter.type = 'search';
+ filter.placeholder = 'Фильтр LoRA…';
+ filter.style.cssText = 'width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem;';
+ picker.appendChild(filter);
+ const list = document.createElement('div');
+ picker.appendChild(list);
+ const draw = () => {
+ list.innerHTML = '';
+ const q = filter.value.trim().toLowerCase();
+ let n = 0;
+ for (const l of inv) {
+ const name = l.name || '';
+ if (q && !String(name).toLowerCase().includes(q) && !String(l.title || '').toLowerCase().includes(q)) {
+ continue;
+ }
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = `${shortLoraName(name)}${l.krea_likely ? ' · krea' : ''}`;
+ btn.title = name;
+ btn.addEventListener('click', async () => {
+ await applyPatch({
+ loras: [
+ ...((() => {
+ try {
+ return (loraHelper?.selected || []).map((x) => ({
+ name: x.name || x,
+ weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[x.name || x]) || 1,
+ }));
+ } catch (e) {
+ return [];
+ }
+ })()),
+ { name, weight: l.default_weight ? parseFloat(l.default_weight) : 0.8, triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []) },
+ ],
+ }, 'loras');
+ picker.remove();
+ renderLoraChips();
+ });
+ list.appendChild(btn);
+ if (++n >= 40) {
+ break;
+ }
+ }
+ if (!n) {
+ list.innerHTML = 'Нет LoRA
';
+ }
+ };
+ filter.addEventListener('input', draw);
+ draw();
+ const composer = $('sa_composer') || document.body;
+ composer.style.position = composer.style.position || 'relative';
+ composer.appendChild(picker);
+ const onDoc = (ev) => {
+ if (!picker.contains(ev.target) && ev.target !== anchor) {
+ picker.remove();
+ document.removeEventListener('mousedown', onDoc);
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
+ filter.focus();
+ }
+
+ function loadTasteFromServer() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentGetTaste',
+ {},
+ (data) => {
+ const remote = data?.taste;
+ if (!remote || typeof remote !== 'object') {
+ return;
+ }
+ const remoteUpdated = remote.updated || 0;
+ const localUpdated = state.taste?.updated || 0;
+ // sqlite taste is the source of truth; localStorage only wins when it is strictly newer.
+ const localEmpty = !localUpdated
+ && !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
+ if (localEmpty || remoteUpdated >= localUpdated) {
+ state.taste = {
+ styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
+ likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
+ avoid: Array.isArray(remote.avoid) ? remote.avoid.slice(0, 12) : [],
+ notes: String(remote.notes || '').slice(0, 400),
+ updated: remoteUpdated || Date.now(),
+ };
+ saveTasteLocalOnly();
+ }
+ },
+ 0,
+ () => {},
+ );
+ }
+
+ function saveTasteLocalOnly() {
+ try {
+ localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
+ } catch (e) { /* ignore */ }
+ }
+
+ function saveTasteToServerDebounced() {
+ if (state.tasteSaveTimer) {
+ clearTimeout(state.tasteSaveTimer);
+ }
+ state.tasteSaveTimer = setTimeout(() => {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentSaveTaste',
+ { taste: state.taste || {} },
+ () => {},
+ 0,
+ () => {},
+ );
+ }, 800);
+ }
+
+ async function generateCardWithAssistent() {
+ const sel = state.cardsSelection;
+ if (!sel) {
+ setCardStatus('Выбери модель');
+ return;
+ }
+ if (state.busy) {
+ setCardStatus('Чат занят');
+ return;
+ }
+ setPackValue('catalog_card', { flash: true });
+ setView('chat');
+ const meta = await new Promise((resolve) => {
+ genericRequest(
+ 'AssistentGetCardMeta',
+ { kind: sel.kind, name: sel.name },
+ (data) => resolve(data),
+ 0,
+ () => resolve(null),
+ );
+ });
+ const forced = `Write a recommendation card for this ${sel.kind}: ${sel.name}. Use metadata/triggers only; output one JSON card.`;
+ await sendChat({
+ forcedUserText: forced,
+ skipSlash: true,
+ skipAutoPack: true,
+ fromCards: true,
+ cardTarget: {
+ kind: sel.kind,
+ name: sel.name,
+ meta,
+ },
+ });
+ }
+
+ function inventoryIsStale(maxAgeMs = 20000) {
+ if (!state.inventoryFetchedAt) {
+ return true;
+ }
+ return (Date.now() - state.inventoryFetchedAt) > maxAgeMs;
+ }
+
+ async function ensureFreshInventory({ forceRescan } = {}) {
+ const rescan = forceRescan || inventoryIsStale(20000);
+ await refreshInventoryAsync({ rescan });
+ }
+
+ function triggerSwarmModelRefresh(done) {
+ if (typeof genericRequest !== 'function') {
+ if (done) {
+ done();
+ }
+ return;
+ }
+ genericRequest(
+ 'TriggerRefresh',
+ { strong: true },
+ () => {
+ if (done) {
+ done();
+ }
+ },
+ 0,
+ () => {
+ if (done) {
+ done();
+ }
+ },
+ );
+ }
+
+ async function handleReplySideEffects(reply, civitaiResults, opts = {}) {
+ const { fromAutoCritique, fromVisionHop, fromCards, fromDebug } = opts;
+ if (fromCards) {
+ const card = extractCardJson(reply);
+ if (card) {
+ if ($('sa_card_json')) {
+ $('sa_card_json').value = JSON.stringify(card, null, 2);
+ }
+ if (opts.fromDownload || opts.cardTarget) {
+ const kind = card.kind || opts.cardTarget?.kind || 'lora';
+ const name = card.name || opts.cardTarget?.name;
+ if (name && typeof genericRequest === 'function') {
+ genericRequest(
+ 'AssistentSaveCard',
+ { kind, name, card, enqueue_wanted: false },
+ (data) => {
+ if (data?.path) {
+ state.modelCards[`${kind}:${name}`] = card;
+ setCardStatus(data.installed ? `Card saved → ${data.path}` : `Card draft → ${data.path}`);
+ setStatus(`Card saved for ${name}`);
+ }
+ },
+ 0,
+ () => setCardStatus('Card draft ready — Save manually'),
+ );
+ }
+ } else {
+ setView('cards');
+ setCardStatus('Draft from Assistent — review & Save');
+ }
+ }
+ return;
+ }
+ const { patch } = extractPatch(reply);
+ let effective = patch;
+ if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug) {
+ const aspect = parseAspectFromUserText(opts.userText || '');
+ if (aspect && (replyMissingJsonPatch(reply) || isSameButAspectRequest(opts.userText || ''))) {
+ effective = { aspect, actions: ['generate'] };
+ if (state.lastPatch?.prompt) {
+ effective.prompt = state.lastPatch.prompt;
+ }
+ appendSystemNote(`Патч пустой — применил aspect ${aspect} сам.`);
+ }
+ }
+ if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug) {
+ const synthesized = synthesizePatchAfterEmptyFence(reply, opts.userText || '', opts);
+ if (synthesized) {
+ effective = synthesized;
+ appendSystemNote('Патч пустой — собрал prompt из ответа и запустил Generate.');
+ }
+ }
+ if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug
+ && !opts.fromEmptyPatchRetry
+ && replyMissingJsonPatch(reply)
+ && (opts.userWantsGenerate || state.pendingSilentGen || userAsksContinue(opts.userText || ''))) {
+ appendSystemNote('Патч пустой — прошу модель дописать JSON.');
+ await sendChat({
+ skipSlash: true,
+ skipAutoPack: true,
+ fromEmptyPatchRetry: true,
+ userWantsGenerate: true,
+ forcedUserText:
+ 'Ты написал «JSON Patch» без fenced ```json```. '
+ + 'Сейчас ответь ТОЛЬКО одним fenced JSON объектом: '
+ + '{"prompt":"","actions":["generate"]}. '
+ + 'prompt — только английский (skill prompting). Без прозы, без ### заголовков.',
+ });
+ return;
+ }
+ if (opts.fromPromptEnRetry && effective) {
+ effective = mergePromptEnRewrite(effective);
+ }
+ if (effective) {
+ rememberLastPatch(effective);
+ }
+ if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
+ doInterruptNow();
+ }
+ if (civitaiResults && civitaiResults.length && $('sa_auto_download')?.checked) {
+ const pick = civitaiResults.find((r) => !r.already_installed && r.download_url && r.krea_likely)
+ || civitaiResults.find((r) => !r.already_installed && r.download_url);
+ if (pick) {
+ downloadCivitaiLoRA(pick, null);
+ }
+ }
+ const suppressGen = !fromVisionHop && !fromAutoCritique && userAsksNoGenerate(opts.userText || '');
+ if (suppressGen && effective) {
+ effective = stripLookAt(stripGenerateAction(effective));
+ state.pendingSilentGen = false;
+ if (effective) {
+ rememberLastPatch(effective);
+ }
+ }
+ if (effective && !fromVisionHop && !fromAutoCritique && !suppressGen) {
+ const hopped = await maybeVisionHop(effective, opts.attachedSlotIds || []);
+ if (hopped) {
+ return;
+ }
+ }
+ if (fromDebug) {
+ // Q&A only — never apply patches / generate from a debug explanation turn.
+ state.pendingSilentGen = false;
+ return;
+ }
+ const wantsGen = !suppressGen && !!(opts.userWantsGenerate || state.pendingSilentGen
+ || (Array.isArray(effective?.actions) && effective.actions.map(String).includes('generate')));
+ const willGen = !!(effective && !fromAutoCritique && !suppressGen
+ && (wantsGen || $('sa_auto_generate')?.checked));
+ // Maximize chat-model prep: structure + EN for Krea before Swarm Generate runs.
+ if (willGen && effective?.prompt && promptNeedsKreaPrep(effective.prompt)
+ && !opts.fromPromptEnRetry && !fromVisionHop && !fromDebug) {
+ state.pendingPromptEnMerge = { ...effective };
+ appendSystemNote('Готовлю промпт для Krea 2 чат-моделью (EN + структура)…');
+ setBusyPhase('refining');
+ await sendChat({
+ skipSlash: true,
+ skipAutoPack: true,
+ fromPromptEnRetry: true,
+ userWantsGenerate: true,
+ forcedUserText: buildKreaPromptPrepRequest(effective),
+ });
+ return;
+ }
+ const doApply = !!(effective && (wantsGen || $('sa_auto_apply')?.checked));
+ if (doApply) {
+ if (wantsGen) {
+ startBusyUi('silent_gen');
+ } else {
+ setBusyPhase('applying');
+ }
+ await applyPatch(effective, 'all');
+ syncLiveParamsBar();
+ updateTasteFromPatch(effective, opts.userText || '');
+ // Auto-Generate must not fire on «запомни / шаблон» turns — even if the model
+ // echoed a prompt patch or sneaked actions:["generate"].
+ if (!fromAutoCritique && !suppressGen && (wantsGen || $('sa_auto_generate')?.checked)) {
+ if (effective?.prompt && promptNeedsKreaPrep(effective.prompt)) {
+ setStatus('Промпт всё ещё не EN/Krea-ready — Generate с тем что есть');
+ }
+ const src = await runGenerateFromPatch(
+ { ...effective, actions: Array.isArray(effective.actions) ? effective.actions : ['generate'] },
+ { force: wantsGen },
+ );
+ if (src) {
+ await maybeAutoCritique(src);
+ await maybeAutoVisionLook(src);
+ }
+ } else if (!state.generating) {
+ stopBusyUi(suppressGen ? 'Запомнил · без Generate' : (wantsGen ? 'Применено' : ''));
+ }
+ } else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) {
+ setStatus('Ответ без JSON-патча — ничего не применено');
+ }
+ state.pendingSilentGen = false;
+ }
+
+ async function applyQuickPatch(patch, note) {
+ const withActions = { ...patch };
+ if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) {
+ withActions.actions = ['generate'];
+ }
+ const prevIntent = state.lastUserParamIntent;
+ state.lastUserParamIntent = true;
+ await applyPatch(withActions, 'all');
+ state.lastUserParamIntent = prevIntent;
+ setStatus(note || 'Applied');
+ if ($('sa_auto_generate')?.checked) {
+ await runGenerateFromPatch(withActions);
+ }
+ syncChipHighlight();
+ }
+
+ function syncChipHighlight() {
+ const bar = $('sa_chips');
+ if (!bar) {
+ return;
+ }
+ const cur = guessAspectFromSize(val('input_width'), val('input_height'));
+ const seed = val('input_seed');
+ bar.querySelectorAll('[data-aspect]').forEach((btn) => {
+ btn.classList.toggle('sa-chip-active', btn.getAttribute('data-aspect') === cur);
+ });
+ bar.querySelectorAll('[data-seed]').forEach((btn) => {
+ const mode = btn.getAttribute('data-seed');
+ const active = (mode === 'lock' && seed && seed !== '-1') || (mode === 'random' && (!seed || seed === '-1'));
+ btn.classList.toggle('sa-chip-active', active);
+ });
+ }
+
+ function appendSystemNote(text) {
+ const box = $('sa_messages');
+ if (!box) {
+ return;
+ }
+ hideChatEmpty();
+ const div = document.createElement('div');
+ div.className = 'sa-msg assistant sa-system-note';
+ div.textContent = text;
+ box.appendChild(div);
+ scrollMessagesToBottom();
+ }
+
+ function clipDebug(s, max) {
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
+ if (!t) {
+ return '—';
+ }
+ return t.length > max ? `${t.slice(0, max)}…` : t;
+ }
+
+ function formatDebugLoras(list) {
+ if (!Array.isArray(list) || !list.length) {
+ return 'нет';
+ }
+ return list.slice(0, 8).map((l) => {
+ const name = l?.name || l;
+ const w = l?.weight != null ? `@${l.weight}` : '';
+ return `${name}${w}`;
+ }).join(', ');
+ }
+
+ function buildDebugSummary() {
+ const persona = $('sa_persona')?.value || 'neutral';
+ const pack = $('sa_pack')?.value || defaultPackId();
+ const chatModel = $('sa_model')?.value || '—';
+ const embed = $('sa_embed_model')?.value || state.preferredEmbed || '—';
+ const profile = detectKreaProfileName();
+ const defaults = mergedGenerationDefaults(profile);
+ const session = state.sessionExact || {};
+ const exactGen = state.exact?.generation || state.config?.exact?.generation || {};
+ const ctx = (() => {
+ try {
+ return collectLiveContext();
+ } catch (e) {
+ return {};
+ }
+ })();
+ const aspect = guessAspectFromSize(ctx.width, ctx.height) || defaults.aspect || '—';
+ const why = [];
+ if (Object.keys(session).length) {
+ why.push(`session_exact перекрывает Exact: ${Object.keys(session).join(', ')}`);
+ } else {
+ why.push('session_exact пуст — params из Exact + профиль чекпоинта');
+ }
+ why.push(`профиль чекпоинта: ${profile} (имя/title → turbo|raw)`);
+ if (persona === 'cinema' || state.exact?.generation?.aspect) {
+ why.push(`persona/exact aspect: ${state.exact?.generation?.aspect || exactGen.aspect || '—'}`);
+ }
+ if (state.lastPatch) {
+ const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null && k !== 'notes');
+ why.push(`последний патч задал: ${keys.slice(0, 12).join(', ')}`);
+ } else {
+ why.push('последнего патча Assistent ещё нет');
+ }
+ why.push('приоритет: user → About the user → session_exact → exact(+persona) → live UI → craft memory_hits');
+
+ const lines = [
+ '### Debug Assistent',
+ `persona=${persona} · pack=${pack}`,
+ `chat=${chatModel} · embed=${embed}`,
+ `skills=${(state.enabledSkills || []).join(',') || '—'}`,
+ `auto: apply=${!!$('sa_auto_apply')?.checked} gen=${!!$('sa_auto_generate')?.checked} vision=${!!$('sa_auto_vision')?.checked} critique=${!!$('sa_auto_critique')?.checked}`,
+ '',
+ 'Live SwarmUI:',
+ ` ckpt=${ctx.checkpoint?.name || '—'} · krea_profile=${ctx.krea_profile || profile}`,
+ ` ${ctx.width || '?'}×${ctx.height || '?'} (${aspect}) · steps=${ctx.steps ?? '—'} · cfg=${ctx.cfg ?? '—'} · sigma=${ctx.sigma_shift ?? '—'} · seed=${ctx.seed ?? '—'} · batch=${ctx.batch ?? '—'}`,
+ ` loras: ${formatDebugLoras(ctx.selected_loras || ctx.enabled_loras)}`,
+ ` available_loras=${(ctx.available_loras || []).length}${ctx.available_loras_truncated ? ` truncated/${ctx.available_loras_total || '?'}` : ''}`,
+ ` prompt: ${clipDebug(ctx.prompt, 220)}`,
+ ` negative: ${clipDebug(ctx.negative, 120)}`,
+ ` init=${!!ctx.has_init_image} mask=${!!ctx.has_mask_image} prompt_images=${ctx.prompt_image_count || 0}`,
+ ` has_vision_image=${!!ctx.has_vision_image} · images_in_request=${!!ctx.images_in_request} · vision_ready=${visionReadySlots().length}`,
+ ` context_json_chars≈${JSON.stringify(ctx).length} · last_system_chars=${state.lastSystemChars || '—'} · last_context_chars=${state.lastContextChars || '—'}`,
+ state.lastSystemLayers
+ ? ` system_layers: ${Object.entries(state.lastSystemLayers).map(([k, v]) => `${k}=${v}`).join(' · ')}`
+ : ' system_layers: — (отправь сообщение, чтобы заполнить)',
+ '',
+ 'Exact defaults (merged):',
+ ` generation=${JSON.stringify(exactGen)}`,
+ ` effective=${JSON.stringify({
+ steps: defaults.steps,
+ cfg: defaults.cfg,
+ sigma_shift: defaults.sigma_shift,
+ aspect: defaults.aspect,
+ images: defaults.images,
+ profile: defaults.profile,
+ })}`,
+ ` session_exact=${Object.keys(session).length ? JSON.stringify(session) : '{}'}`,
+ '',
+ 'Почему так:',
+ ...why.map((w) => ` · ${w}`),
+ ];
+ if (state.lastPatch) {
+ lines.push('', `last_patch: ${clipDebug(JSON.stringify(state.lastPatch), 360)}`);
+ }
+ return lines.join('\n');
+ }
+
+ async function handleSlashCommand(raw) {
+ const text = String(raw || '').trim();
+ if (!text.startsWith('/')) {
+ return false;
+ }
+ const parts = text.slice(1).split(/\s+/);
+ const cmd = (parts[0] || '').toLowerCase();
+ const arg = parts.slice(1).join(' ').trim();
+
+ if (cmd === 'help' || cmd === '?') {
+ appendSystemNote(HELP_TEXT);
+ setStatus('/help');
+ return true;
+ }
+ if (cmd === 'new' || cmd === 'newchat') {
+ await startNewChat({ saveCurrent: true });
+ return true;
+ }
+ if (cmd === 'history' || cmd === 'chats' || cmd === 'sessions') {
+ setChatsPanelOpen(true);
+ setStatus('/history');
+ return true;
+ }
+ if (cmd === 'debug' || cmd === 'dbg' || cmd === 'why') {
+ const dump = buildDebugSummary();
+ appendSystemNote(dump);
+ const argL = String(arg || '').toLowerCase().trim();
+ const wantLlm = cmd === 'why'
+ || /^(ask|llm|explain|поясни|почему|модель)(\s|$)/i.test(argL);
+ if (wantLlm) {
+ setStatus('/debug ask…');
+ await sendChat({
+ forcedUserText:
+ 'Отладка Assistent. Ниже факты UI (уже собраны клиентом). ' +
+ 'Кратко своими словами (5–10 строк, язык пользователя): какие промпт/params сейчас, ' +
+ 'что из Exact vs session_exact vs live, что сделал последний патч и почему так логично. ' +
+ 'Без JSON patch, без generate, без look_at.\n\n' + dump,
+ skipSlash: true,
+ skipAutoPack: true,
+ fromDebug: true,
+ });
+ } else {
+ setStatus('/debug');
+ }
+ return true;
+ }
+ if (cmd === 'gen' || cmd === 'generate') {
+ const prev = findCurrentGenerateSrc();
+ startBusyUi('generating');
+ state.generating = true;
+ setInterruptVisible(true);
+ if (!triggerGenerate()) {
+ state.generating = false;
+ stopBusyUi('Could not start Generate');
+ return true;
+ }
+ const src = await waitForNewImage(prev);
+ state.generating = false;
+ setInterruptVisible(state.busy);
+ if (src) {
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = src;
+ renderBoard();
+ }
+ stopBusyUi('Generate done');
+ } else {
+ stopBusyUi('Generate finished');
+ }
+ return true;
+ }
+ if (cmd === 'look') {
+ const id = normalizeSlotId(arg || GEN_ID) || GEN_ID;
+ const slot = slotById(id);
+ if (!slot) {
+ setStatus(`Неизвестный слот: ${arg || GEN_ID}`);
+ return true;
+ }
+ if (slot.type !== 'generate') {
+ setBoardTab('refs');
+ } else {
+ setBoardTab('generate');
+ }
+ if (!slot.src && id === GEN_ID) {
+ const src = findCurrentGenerateSrc();
+ if (src) {
+ slot.src = src;
+ }
+ }
+ if (!slot.src) {
+ setStatus(`Slot ${id} is empty`);
+ return true;
+ }
+ slot.attach = true;
+ renderBoard();
+ if ($('sa_input')) {
+ $('sa_input').value = `Look at ${id} and describe what you see.`;
+ }
+ setPackValue('critique_image', { flash: true });
+ await sendChat({ forceSlotIds: [id], skipAutoPack: true });
+ return true;
+ }
+ if (cmd === 'init') {
+ const src = selectedSrc() || findCurrentGenerateSrc();
+ if (!src) {
+ setStatus('No image for Init');
+ return true;
+ }
+ await setInitFromSrc(src);
+ setPackValue('inpaint_edit', { flash: true });
+ return true;
+ }
+ if (cmd === 'mask') {
+ const src = selectedSrc();
+ if (!src) {
+ setStatus('Select a window with a mask image');
+ return true;
+ }
+ await setMaskFromSrc(src);
+ setPackValue('inpaint_edit', { flash: true });
+ return true;
+ }
+ if (cmd === 'clear') {
+ clearInitAndMask();
+ return true;
+ }
+ if (cmd === 'interrupt' || cmd === 'stop') {
+ doInterruptNow();
+ clearInFlightUi({ status: 'Прервано' });
+ return true;
+ }
+ if (cmd === 'aspect') {
+ const key = normalizeAspect(arg);
+ if (!key) {
+ setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`);
+ return true;
+ }
+ await applyQuickPatch({ aspect: key, actions: ['generate'] }, `Aspect ${key}`);
+ return true;
+ }
+ if (cmd === 'seed') {
+ const mode = (arg || 'random').toLowerCase();
+ if (mode === 'lock' || mode === 'keep') {
+ await applyQuickPatch({ lock_seed: true }, 'Seed locked');
+ } else {
+ await applyQuickPatch({ seed: -1, vary: true, actions: ['generate'] }, 'Seed random');
+ }
+ return true;
+ }
+ if (cmd === 'vary') {
+ await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)');
+ return true;
+ }
+ if (cmd === 'inventory' || cmd === 'inv') {
+ setStatus('Rescanning models…');
+ triggerSwarmModelRefresh(async () => {
+ await refreshInventoryAsync({ rescan: true });
+ const n = state.inventory?.loras?.length || 0;
+ const ck = state.inventory?.checkpoints?.length || 0;
+ appendSystemNote(`Inventory refreshed: ${n} LoRAs, ${ck} checkpoints.`);
+ setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts (rescanned)`);
+ });
+ return true;
+ }
+ if (cmd === 'pack') {
+ if (!setPackValue(arg, { flash: true, user: true })) {
+ setStatus('Pack: write|critique|compose|params|inpaint|describe');
+ } else {
+ setStatus(`Pack → ${$('sa_pack')?.value}`);
+ }
+ return true;
+ }
+ if (cmd === 'остынь' || cmd === 'ostyn' || cmd === 'cool' || cmd === 'cooldown') {
+ coolDownHorny();
+ return true;
+ }
+ if (cmd === 'horny-game' || cmd === 'hornygame' || cmd === 'horny_game') {
+ await startHornyGame();
+ return true;
+ }
+ if (cmd === 'civitai') {
+ if (!arg) {
+ setStatus('/civitai ');
+ return true;
+ }
+ if ($('sa_input')) {
+ $('sa_input').value = `Find a Krea 2 LoRA for: ${arg}`;
+ }
+ setPackValue(defaultPackId(), { flash: true });
+ await sendChat({
+ skipAutoPack: true,
+ forcedUserText: `Search Civitai for Krea-compatible LoRA: ${arg}. Prefer actions search_civitai.`,
+ });
+ return true;
+ }
+ if (cmd === 'persona') {
+ const sub = (parts[1] || 'new').toLowerCase();
+ const rest = parts.slice(2).join(' ').trim();
+ setPackValue('author_persona', { flash: true, user: true });
+ if (sub === 'save') {
+ await sendChat({
+ skipAutoPack: true,
+ forcedUserText:
+ 'Сохрани согласованный черновик личности сейчас (persona_clone / persona_write). Не удаляй личности.',
+ });
+ return true;
+ }
+ const fromId = sub === 'clone' && rest
+ ? rest.split(/\s+/)[0]
+ : ($('sa_persona')?.value || 'neutral');
+ await sendChat({
+ skipAutoPack: true,
+ forcedUserText:
+ `Начни интервью author_persona: клон с источника «${fromId}». ` +
+ 'Спрашивай по полкам группами. Не пиши на диск, пока мало ответов. Не удаляй личности.',
+ });
+ return true;
+ }
+
+ appendSystemNote(`Unknown command /${cmd}.\n\n${HELP_TEXT}`);
+ setStatus(`Unknown /${cmd}`);
+ return true;
+ }
+
+ async function maybeVisionHop(patch, attachedSlotIds) {
+ const ids = lookAtIdsFromPatch(patch);
+ if (!ids.length || state.visionHopUsed) {
+ return false;
+ }
+ scrubPreviewFromGenerateSlot();
+ const have = ids.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
+ if (!have.length) {
+ const genSrc = findCurrentGenerateSrc();
+ if (ids.includes(GEN_ID) && genSrc) {
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = genSrc;
+ have.push(gen);
+ }
+ }
+ }
+ if (!have.length) {
+ setStatus('look_at: нет реального кадра (превью модели пропущено)');
+ return false;
+ }
+ const already = new Set(attachedSlotIds || []);
+ const need = have.filter((s) => !already.has(s.id));
+ if (!need.length) {
+ return false;
+ }
+ state.visionHopUsed = true;
+ for (const s of need) {
+ s.attach = true;
+ }
+ renderBoard();
+ if ($('sa_input')) {
+ $('sa_input').value = `Look at board slots: ${need.map((s) => s.id).join(', ')}. Continue using these images.`;
+ }
+ setStatus(`Vision hop ← ${need.map((s) => s.label).join(', ')}`);
+ await sendChat({ fromVisionHop: true, forceSlotIds: need.map((s) => s.id) });
+ return true;
+ }
+
+ async function sendChat(opts = {}) {
+ if ((state.busy || state.generating) && !opts.fromVisionHop && !opts.fromAutoCritique) {
+ return;
+ }
+ const rawInput = ($('sa_input')?.value || '').trim();
+ const text = (opts.forcedUserText || rawInput).trim();
+ if (!text) {
+ return;
+ }
+ if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
+ state.lastUserParamIntent = userTextMentionsParams(text);
+ state.lastUserControlIntent = userTextMentionsControls(text);
+ state.pendingSilentGen = userAsksGenerate(text) || userAsksContinue(text) || isSameButAspectRequest(text);
+ if (userAsksNoGenerate(text)) {
+ state.pendingSilentGen = false;
+ }
+ }
+
+ if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) {
+ if (rawInput.startsWith('/')) {
+ if ($('sa_input')) {
+ $('sa_input').value = '';
+ }
+ const handled = await handleSlashCommand(rawInput);
+ if (handled) {
+ return;
+ }
+ }
+ }
+
+ // «такую же, только 9 на 16» — apply aspect + Generate without waiting for an empty LLM critique.
+ if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug
+ && !opts.fromCards && isSameButAspectRequest(text)) {
+ const aspect = parseAspectFromUserText(text);
+ if (aspect) {
+ if ($('sa_input')) {
+ $('sa_input').value = '';
+ }
+ appendMessage('user', text);
+ state.history.push({ role: 'user', content: text });
+ persistHistory();
+ restoreDefaultPackAfterHop();
+ const patch = { aspect, actions: ['generate'] };
+ if (state.lastPatch?.prompt) {
+ patch.prompt = state.lastPatch.prompt;
+ }
+ if (Array.isArray(state.lastPatch?.loras) && state.lastPatch.loras.length) {
+ patch.loras = state.lastPatch.loras;
+ }
+ appendSystemNote(`Ставлю ${aspect} и Generate (тот же промпт) — без повторной критики.`);
+ await applyQuickPatch(patch, `Aspect ${aspect}`);
+ return;
+ }
+ }
+
+ if (!updateGate()) {
+ setStatus('Выбери модель Krea 2');
+ return;
+ }
+
+ if (!opts.skipAutoPack && !opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromCards) {
+ const guessed = autoSelectPack(text);
+ if (guessed) {
+ setPackValue(guessed, { flash: true });
+ }
+ }
+
+ // Cards mode must not be overridden by auto-pack; keep catalog_card.
+ if (opts.fromCards || state.view === 'cards') {
+ setPackValue('catalog_card', { flash: false });
+ }
+
+ const pack = $('sa_pack')?.value || defaultPackId();
+ const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
+ const model = $('sa_model')?.value;
+ if (!model) {
+ setStatus('Выбери модель Ollama в ⚙');
+ refreshModels();
+ return;
+ }
+
+ const chatEpoch = bumpChatEpoch();
+ state.busy = true;
+ // If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here.
+ state.llmParked = false;
+ setInterruptVisible(true);
+ if (state.expectColdLoad && !opts.fromVisionHop && !opts.fromAutoCritique) {
+ startBusyUi('warming');
+ setStatus('Возвращаю LLM в GPU…');
+ try {
+ await warmLlm({ force: true });
+ } catch (e) {
+ console.warn('Assistent warm before send', e);
+ }
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ }
+ startBusyUi(state.expectColdLoad ? 'loading' : 'thinking');
+ saveSettings();
+
+ // Always pull latest LoRA/checkpoint list before the LLM sees context
+ // (rescans disk when inventory is older than ~20s or after downloads).
+ setStatus('Обновляю inventory…');
+ try {
+ await ensureFreshInventory({ forceRescan: !!opts.fromDownload });
+ await prefetchActiveModelCards();
+ } catch (e) {
+ console.warn('Assistent inventory refresh', e);
+ }
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+
+ if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop) {
+ state.critiqueHopUsed = false;
+ state.visionHopUsed = false;
+ }
+
+ let wantedIds = (opts.forceSlotIds || []).map(normalizeSlotId).filter(Boolean);
+ // JPEG only on look_at / vision hop — never auto-attach every board ref to ordinary chat.
+ const sendVision = !!(opts.fromVisionHop || (wantedIds.length && opts.forceSlotIds));
+ if (!sendVision) {
+ wantedIds = [];
+ }
+ const visionSlots = wantedIds.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
+ let images = null;
+ if (visionSlots.length) {
+ startBusyUi('encoding');
+ setStatus('Кодирую изображение…');
+ images = [];
+ for (const slot of visionSlots) {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ const b64 = await imageToBase64ForOllama(slot.src);
+ if (b64) {
+ images.push(b64);
+ }
+ }
+ if (!images.length) {
+ images = null;
+ }
+ }
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+
+ const msgMeta = {
+ persona: currentPersonaInfo(),
+ pack,
+ silentPatch: !!state.pendingSilentGen,
+ };
+ state.history.push({ role: 'user', content: text });
+ if (state.pendingPersonaNote) {
+ state.history.push({ role: 'user', content: state.pendingPersonaNote, systemish: true });
+ state.pendingPersonaNote = null;
+ }
+ appendMessage('user', text);
+ $('sa_input').value = '';
+ persistHistory();
+
+ const context = collectLiveContext();
+ // has_vision_image = board has a real frame (even when JPEG is not in this request).
+ // images_in_request = JPEG bytes are attached to the last user message this turn.
+ context.has_vision_image = visionReadySlots().length > 0;
+ context.images_in_request = !!(images && images.length);
+ context.attached_slot_ids = attachableSlots().map((s) => s.id);
+ context.vision_slot_ids = visionSlots.map((s) => s.id);
+ context.persona = persona;
+ if (opts.cardTarget) {
+ context.card_target = opts.cardTarget;
+ }
+ if (opts.fromCards || pack === 'catalog_card') {
+ context.auto_apply = false;
+ context.auto_generate = false;
+ }
+ await prefetchActiveModelCards();
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ // Refresh cards into context after prefetch
+ const refreshed = collectLiveContext();
+ context.model_cards = refreshed.model_cards;
+ const messages = state.history.slice(-historyMessageLimit()).map((m) => {
+ let content = String(m.content || '');
+ if (m.role === 'assistant') {
+ content = stripJsonFencesForHistory(content);
+ }
+ return { role: m.role, content: content.slice(0, 4000) };
+ });
+ if (images && messages.length) {
+ messages[messages.length - 1].images = images;
+ }
+
+ startBusyUi('thinking');
+
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ // Flat fields: SwarmUI JObject params receive the whole request body.
+ const payload = {
+ baseUrl,
+ model,
+ pack,
+ persona,
+ includeBase: true,
+ messages,
+ context_json: JSON.stringify(context),
+ skills: state.enabledSkills || [],
+ embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
+ };
+
+ const finishOk = async (reply, civitaiResults, meta = {}) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ if (meta.system_chars != null) {
+ state.lastSystemChars = Number(meta.system_chars) || 0;
+ }
+ if (meta.system_layers && typeof meta.system_layers === 'object') {
+ state.lastSystemLayers = meta.system_layers;
+ }
+ try {
+ state.lastContextChars = (context && JSON.stringify(context).length) || 0;
+ } catch (e) {
+ state.lastContextChars = 0;
+ }
+ const prose = extractPatch(reply).prose || reply;
+ state.history.push({ role: 'assistant', content: prose, persona, pack });
+ persistHistory();
+ setBusyPhase(state.pendingSilentGen || userAsksGenerate(text) || userAsksContinue(text) ? 'silent_gen' : 'thinking');
+ try {
+ await handleReplySideEffects(reply, civitaiResults, {
+ ...opts,
+ userText: text,
+ userWantsGenerate: !!state.pendingSilentGen || userAsksGenerate(text) || userAsksContinue(text),
+ attachedSlotIds: visionSlots.map((s) => s.id),
+ });
+ } finally {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ // Keep busy while silent Apply+Generate is still running (generating flag).
+ if (!state.generating) {
+ state.busy = false;
+ setInterruptVisible(false);
+ stopBusyUi('Готово');
+ } else {
+ state.busy = false;
+ setInterruptVisible(true);
+ }
+ }
+ };
+
+ const finishErr = (msg) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ state.busy = false;
+ setInterruptVisible(state.generating);
+ stopBusyUi(msg);
+ if (state.streamEl) {
+ state.streamEl.classList.remove('sa-streaming', 'sa-typing');
+ state.streamEl.classList.add('error');
+ setAssistantBody(state.streamEl, msg);
+ state.streamEl = null;
+ state.streamMeta = null;
+ } else {
+ appendMessage('error', msg);
+ }
+ };
+
+ if (typeof makeWSRequest === 'function') {
+ beginStreamMessage(msgMeta);
+ makeWSRequest(
+ 'AssistentChatWS',
+ payload,
+ (data) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ if (data.phase === 'waiting_ollama') {
+ // Server always emits this before /api/chat — not proof of a cold load.
+ setBusyPhase(state.expectColdLoad ? 'loading' : 'waiting');
+ const label = state.streamEl?.querySelector('.sa-typing-label');
+ if (label) {
+ label.textContent = state.expectColdLoad
+ ? `Загружаю ${modelShort(model)} в GPU…`
+ : 'Думаю…';
+ }
+ return;
+ }
+ if (data.error) {
+ finishErr(String(data.error));
+ return;
+ }
+ if (data.clear_stream) {
+ if (state.streamEl) {
+ state.streamEl.classList.add('sa-typing');
+ const body = state.streamEl.querySelector('.sa-msg-body') || state.streamEl;
+ body.innerHTML = 'Уточняю… ';
+ }
+ setBusyPhase('refining');
+ return;
+ }
+ if (data.delta) {
+ appendStreamDelta(data.delta);
+ return;
+ }
+ if (data.done || data.reply != null) {
+ const reply = data.reply || (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || '';
+ const civitai = data.civitai_results || [];
+ finalizeStreamMessage(reply, civitai);
+ finishOk(reply, civitai, { system_chars: data.system_chars, system_layers: data.system_layers });
+ }
+ },
+ 0,
+ (err) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ // Fallback to HTTP AssistentChat
+ console.warn('AssistentChatWS failed, falling back', err);
+ if (state.streamEl) {
+ state.streamEl.remove();
+ state.streamEl = null;
+ state.streamMeta = null;
+ }
+ genericRequest(
+ 'AssistentChat',
+ payload,
+ (data) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ if (data.error) {
+ finishErr(String(data.error));
+ return;
+ }
+ const reply = data.reply || '';
+ appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
+ finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
+ },
+ 0,
+ (err2) => finishErr(String(err2 || err || 'Chat failed')),
+ );
+ },
+ );
+ return;
+ }
+
+ genericRequest(
+ 'AssistentChat',
+ payload,
+ (data) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
+ if (data.error) {
+ finishErr(String(data.error));
+ return;
+ }
+ const reply = data.reply || '';
+ appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
+ finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
+ },
+ 0,
+ (err) => finishErr(String(err || 'Chat failed')),
+ );
+ }
+
+ function wireDropZone() {
+ const board = $('sa_board');
+ const layout = $('sa_layout');
+ layout?.addEventListener('dragover', (e) => {
+ if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/uri-list')) {
+ e.preventDefault();
+ }
+ });
+ layout?.addEventListener('drop', async (e) => {
+ if (!e.dataTransfer) {
+ return;
+ }
+ if (e.target && e.target.closest && e.target.closest('.sa-slot, .sa-add-cell')) {
+ return;
+ }
+ e.preventDefault();
+ await handleDropDataTransfer(e.dataTransfer);
+ });
+ board?.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && state.lightboxIndex >= 0) {
+ e.preventDefault();
+ closeGenLightbox();
+ return;
+ }
+ if ((e.key === 'Enter' || e.key === ' ') && state.boardTab === 'generate' && state.selectedGenResultId) {
+ const row = (state.genResults || []).find((r) => r.id === state.selectedGenResultId);
+ if (row?.src) {
+ e.preventDefault();
+ openGenLightbox(row.id);
+ return;
+ }
+ }
+ if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && state.lightboxIndex >= 0) {
+ e.preventDefault();
+ stepGenLightbox(e.key === 'ArrowRight' ? 1 : -1);
+ return;
+ }
+ if (e.key === 'Delete' || e.key === 'Backspace') {
+ if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
+ return;
+ }
+ e.preventDefault();
+ clearSlot(state.selectedSlotId);
+ }
+ });
+
+ document.addEventListener('paste', async (e) => {
+ const pane = document.getElementById('assistent');
+ if (!pane || !pane.classList.contains('active')) {
+ return;
+ }
+ if (e.target && (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT')) {
+ const items = e.clipboardData?.items;
+ let hasImage = false;
+ if (items) {
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ hasImage = true;
+ break;
+ }
+ }
+ }
+ if (!hasImage) {
+ return;
+ }
+ }
+ const items = e.clipboardData?.items;
+ if (!items) {
+ return;
+ }
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ e.preventDefault();
+ const file = item.getAsFile();
+ if (file) {
+ const sel = selectedSlot();
+ await acceptImageFile(file, sel && sel.type === 'ref' ? sel.id : null);
+ }
+ return;
+ }
+ }
+ });
+ }
+
+ function wireSplitter() {
+ const splitter = $('sa_splitter');
+ const layout = $('sa_layout');
+ const pane = $('sa_image_pane');
+ if (!splitter || !layout || !pane) {
+ return;
+ }
+ let dragging = false;
+ splitter.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ dragging = true;
+ splitter.classList.add('sa-dragging');
+ document.body.style.cursor = 'col-resize';
+ document.body.style.userSelect = 'none';
+ });
+ window.addEventListener('mousemove', (e) => {
+ if (!dragging) {
+ return;
+ }
+ const rect = layout.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const pct = Math.min(56, Math.max(22, (x / rect.width) * 100));
+ const value = `${pct}%`;
+ document.documentElement.style.setProperty('--sa-image-width', value);
+ localStorage.setItem(LS_PANE_WIDTH, value);
+ });
+ window.addEventListener('mouseup', () => {
+ if (!dragging) {
+ return;
+ }
+ dragging = false;
+ splitter.classList.remove('sa-dragging');
+ document.body.style.cursor = '';
+ document.body.style.userSelect = '';
+ saveUiStateToDisk();
+ });
+ }
+
+ function registerSendButton() {
+ if (typeof registerMediaButton !== 'function') {
+ setTimeout(registerSendButton, 500);
+ return;
+ }
+ if (window.__swarmAssistentMediaRegistered) {
+ return;
+ }
+ window.__swarmAssistentMediaRegistered = true;
+ registerMediaButton(
+ 'Send to Assistent',
+ (src) => {
+ putImageOnBoard(src, {
+ switchTab: true,
+ note: 'Image sent to Assistent',
+ preferSelected: false,
+ });
+ const pack = $('sa_pack');
+ if (pack && (pack.value === 'ordinary' || pack.value === 'write_prompt')) {
+ pack.value = 'critique_image';
+ saveSettings();
+ }
+ },
+ 'Open Assistent with this image (vision / critique / prompt help)',
+ ['image'],
+ true,
+ true,
+ );
+ }
+
+ /** Disk state first (chats + UI prefs), then config / models / inventory. */
+ async function bootstrapPersisted() {
+ try {
+ await applyDiskUiState();
+ } catch (e) {
+ console.warn('Assistent: ui-state restore failed', e);
+ }
+ try {
+ await initChatSessions();
+ } catch (e) {
+ console.warn('Assistent: chat sessions failed', e);
+ }
+ loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
+ refreshModels();
+ refreshInventory(() => {
+ renderCardsList();
+ renderLoraChips();
+ });
+ });
+ probeOllamaHealth();
+ refreshWantedQueue();
+ }
+
+ function wire() {
+ if (!$('swarm_assistent_root')) {
+ return;
+ }
+ if (typeof genericRequest !== 'function') {
+ setTimeout(wire, 300);
+ return;
+ }
+ if (window.__swarmAssistentWired) {
+ return;
+ }
+ window.__swarmAssistentWired = true;
+ loadSettings();
+ loadTaste();
+ loadTasteFromServer();
+ setView(state.view || 'chat');
+ updateGate();
+ ensureBoard();
+ setBoardTab(state.boardTab || 'generate', { persist: false });
+ syncGenerateSlot();
+ if (wantsAutoVision()) {
+ refreshImagePreview();
+ }
+ bootstrapPersisted();
+ wireDropZone();
+ wireSplitter();
+ registerSendButton();
+ wireSlashInput();
+ wireCardForm();
+
+ $('sa_btn_new_chat')?.addEventListener('click', () => startNewChat({ saveCurrent: true }));
+ $('sa_btn_chats')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ setChatsPanelOpen(!state.chatsPanelOpen);
+ });
+ $('sa_session_label')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ setChatsPanelOpen(!state.chatsPanelOpen);
+ });
+ $('sa_session_label')?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setChatsPanelOpen(!state.chatsPanelOpen);
+ }
+ });
+ $('sa_chats_panel')?.addEventListener('click', (e) => e.stopPropagation());
+ $('sa_chats_list')?.addEventListener('click', (e) => {
+ const row = e.target.closest('.sa-chat-row');
+ if (!row) {
+ return;
+ }
+ const id = row.dataset.id;
+ if (e.target.closest('[data-del]')) {
+ e.preventDefault();
+ if (window.confirm('Удалить этот чат из истории?')) {
+ deleteChat(id);
+ }
+ return;
+ }
+ if (e.target.closest('[data-open]')) {
+ switchToChat(id);
+ }
+ });
+ let chatsSearchTimer = null;
+ $('sa_chats_search')?.addEventListener('input', () => {
+ const q = ($('sa_chats_search')?.value || '').trim();
+ state.chatsQuery = q;
+ if (!q) {
+ state.chatsSearchHits = null;
+ renderChatsList();
+ return;
+ }
+ renderChatsList();
+ clearTimeout(chatsSearchTimer);
+ chatsSearchTimer = setTimeout(async () => {
+ try {
+ const hits = await diskPersist()?.searchChats?.(q);
+ if ((state.chatsQuery || '') !== q) {
+ return;
+ }
+ state.chatsSearchHits = Array.isArray(hits) ? hits : [];
+ renderChatsList();
+ } catch (e) { /* ignore */ }
+ }, 220);
+ });
+
+ $('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
+ $('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
+ $('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
+ $('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
+ $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
+ $('sa_persona')?.addEventListener('change', onPersonaChanged);
+ $('sa_persona_delete')?.addEventListener('click', () => deleteCurrentOverlayPersona());
+ $('sa_cards_kind')?.addEventListener('change', renderCardsList);
+ $('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true }));
+ $('sa_btn_card_meta')?.addEventListener('click', () => fetchCardMetaLive());
+ $('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
+ $('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
+ $('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
+ $('sa_btn_settings')?.addEventListener('click', () => {
+ if (state.view === 'settings') {
+ closeSettings();
+ } else {
+ openSettings(state.settingsTab || 'behavior');
+ }
+ });
+ $('sa_settings_close')?.addEventListener('click', () => closeSettings());
+ document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
+ btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
+ });
+ $('sa_btn_mem_refresh')?.addEventListener('click', () => {
+ refreshMemoryList();
+ refreshWantedQueue();
+ });
+ $('sa_mem_kind')?.addEventListener('change', renderMemoryList);
+ $('sa_mem_scope')?.addEventListener('change', renderMemoryList);
+ $('sa_mem_search')?.addEventListener('input', () => renderMemoryList());
+ $('sa_btn_mem_clear_kind')?.addEventListener('click', () => {
+ const kind = memoryKindFilter();
+ clearCraftMemory({ kind: kind === 'all' ? '' : kind, label: kind === 'all' ? 'весь крафт (фильтр типа)' : `тип ${kind}` });
+ });
+ $('sa_btn_mem_clear_persona')?.addEventListener('click', () => {
+ clearCraftMemory({ scope: 'personal', persona: $('sa_persona')?.value || 'neutral', label: 'крафт этой личности' });
+ });
+ $('sa_btn_mem_clear_shared')?.addEventListener('click', () => {
+ clearCraftMemory({ scope: 'shared', label: 'общую крафт-память' });
+ });
+ $('sa_btn_mem_clear_all')?.addEventListener('click', () => {
+ clearCraftMemory({ label: 'весь крафт (non-bundled)' });
+ });
+ $('sa_btn_prefs_refresh')?.addEventListener('click', () => refreshUserPrefs());
+ $('sa_btn_pref_add_global')?.addEventListener('click', () => addUserPref('global'));
+ $('sa_btn_pref_add_persona')?.addEventListener('click', () => addUserPref('persona'));
+ $('sa_btn_prefs_clear_global')?.addEventListener('click', () => clearUserPrefs('global'));
+ $('sa_btn_prefs_clear_persona')?.addEventListener('click', () => clearUserPrefs('persona'));
+ $('sa_btn_prefs_clear_all')?.addEventListener('click', () => clearUserPrefs('all'));
+ $('sa_user_prefs_weight')?.addEventListener('input', () => {
+ const lab = $('sa_user_prefs_weight_val');
+ if (lab) {
+ lab.textContent = Number($('sa_user_prefs_weight').value).toFixed(1);
+ }
+ });
+ $('sa_user_prefs_weight')?.addEventListener('change', () => saveKnobs());
+ $('sa_memory_top_k')?.addEventListener('change', () => saveKnobs());
+ $('sa_btn_knobs_save')?.addEventListener('click', () => saveKnobs());
+ $('sa_btn_reset_ui')?.addEventListener('click', () => resetUiState());
+ $('sa_btn_persona_export')?.addEventListener('click', () => exportSelectedPersona());
+ $('sa_btn_persona_import')?.addEventListener('click', () => $('sa_persona_import_file')?.click());
+ $('sa_persona_import_file')?.addEventListener('change', (e) => {
+ const file = e.target?.files?.[0];
+ if (file) {
+ importPersonaFile(file);
+ }
+ e.target.value = '';
+ });
+ $('sa_btn_persona_clone')?.addEventListener('click', () => cloneSelectedPersona());
+ $('sa_btn_persona_delete_panel')?.addEventListener('click', () => deleteSelectedOverlayPersona());
+ $('sa_btn_settings_health')?.addEventListener('click', () => {
+ probeOllamaHealth();
+ setTimeout(syncSettingsHealthLine, 400);
+ });
+ $('sa_settings_chat_model')?.addEventListener('change', () => {
+ const v = $('sa_settings_chat_model')?.value;
+ if (v && $('sa_model')) {
+ $('sa_model').value = v;
+ saveSettings();
+ }
+ });
+ $('sa_btn_look_result')?.addEventListener('click', () => askLookAtResult());
+ $('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
+ document.addEventListener('keydown', (e) => {
+ if (state.lightboxIndex >= 0) {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ closeGenLightbox();
+ return;
+ }
+ if (e.key === 'ArrowLeft') {
+ e.preventDefault();
+ stepGenLightbox(-1);
+ return;
+ }
+ if (e.key === 'ArrowRight') {
+ e.preventDefault();
+ stepGenLightbox(1);
+ return;
+ }
+ }
+ if (e.key !== 'Escape') {
+ return;
+ }
+ let closed = false;
+ if (state.view === 'settings') {
+ closeSettings();
+ closed = true;
+ }
+ if (state.chatsPanelOpen) {
+ setChatsPanelOpen(false);
+ closed = true;
+ }
+ const slash = $('sa_slash_menu');
+ if (slash && !slash.hidden) {
+ slash.hidden = true;
+ closed = true;
+ }
+ closeAllMoreMenus();
+ if (closed) {
+ e.preventDefault();
+ }
+ });
+ // Focus composer when Assistent tab becomes visible
+ document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => {
+ setTimeout(() => $('sa_input')?.focus(), 80);
+ });
+ $('sa_btn_refresh_models')?.addEventListener('click', () => {
+ saveSettings();
+ refreshModels();
+ probeOllamaHealth();
+ });
+ $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
+ renderCardsList();
+ renderLoraChips();
+ }, { rescan: true }));
+ $('sa_btn_add_ref')?.addEventListener('click', () => {
+ setBoardTab('refs');
+ addRefSlot({ select: true });
+ });
+ $('sa_btn_use_current')?.addEventListener('click', () => snapshotGenerateToRef());
+ $('sa_btn_as_init')?.addEventListener('click', async () => {
+ closeAllMoreMenus();
+ const src = selectedSrc() || findCurrentGenerateSrc();
+ if (!src) {
+ setStatus('Нет изображения для Init');
+ return;
+ }
+ await setInitFromSrc(src);
+ const pack = $('sa_pack');
+ if (pack && (pack.value === 'ordinary' || pack.value === 'write_prompt')) {
+ setPackValue('inpaint_edit', { flash: true });
+ }
+ });
+ $('sa_btn_as_mask')?.addEventListener('click', async () => {
+ closeAllMoreMenus();
+ const src = selectedSrc();
+ if (!src) {
+ setStatus('Выбери окно с маской');
+ return;
+ }
+ await setMaskFromSrc(src);
+ setPackValue('inpaint_edit', { flash: true });
+ });
+ $('sa_btn_clear_init')?.addEventListener('click', () => {
+ clearInitAndMask();
+ closeAllMoreMenus();
+ });
+ $('sa_btn_clear_image')?.addEventListener('click', () => clearSlot(state.selectedSlotId));
+ $('sa_btn_board_more')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleMoreMenu('sa_board_more_menu', 'sa_btn_board_more');
+ });
+ $('sa_btn_send')?.addEventListener('click', () => sendChat());
+ $('sa_btn_build_gen')?.addEventListener('click', () => buildCurrentAndGenerate());
+ $('sa_btn_interrupt')?.addEventListener('click', () => {
+ doInterruptNow();
+ clearInFlightUi({ status: 'Прервано' });
+ });
+ $('sa_btn_clear')?.addEventListener('click', () => {
+ if (window.confirm('Очистить весь чат Assistent?')) {
+ clearChatHistory();
+ }
+ });
+ $('sa_btn_clear_more')?.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleMoreMenu('sa_clear_more_menu', 'sa_btn_clear_more');
+ });
+ $('sa_btn_clear_confirm')?.addEventListener('click', () => {
+ closeAllMoreMenus();
+ if (window.confirm('Очистить весь чат Assistent?')) {
+ clearChatHistory();
+ }
+ });
+ $('sa_btn_clear_patches')?.addEventListener('click', () => {
+ closeAllMoreMenus();
+ clearPatchBlocksOnly();
+ });
+ $('sa_btn_card_to_chat')?.addEventListener('click', () => {
+ if (state.cardsSelection) {
+ sendCardToChat(state.cardsSelection);
+ } else {
+ setCardStatus('Сначала выбери модель в списке');
+ }
+ });
+ document.addEventListener('click', () => {
+ if (state.chatsPanelOpen) {
+ setChatsPanelOpen(false);
+ }
+ closeAllMoreMenus();
+ });
+ $('sa_board_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
+ $('sa_clear_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
+ $('sa_base_url')?.addEventListener('change', saveSettings);
+ $('sa_model')?.addEventListener('change', () => {
+ const v = $('sa_model')?.value;
+ if (v && $('sa_settings_chat_model')) {
+ $('sa_settings_chat_model').value = v;
+ }
+ saveSettings();
+ });
+ $('sa_embed_model')?.addEventListener('change', () => {
+ state.preferredEmbed = $('sa_embed_model')?.value || '';
+ saveSettings();
+ });
+ $('sa_pack')?.addEventListener('change', () => {
+ state.packUserTouched = true;
+ saveSettings();
+ syncModeBadge();
+ });
+ $('sa_chips')?.addEventListener('click', async (e) => {
+ const btn = e.target.closest('.sa-chip');
+ if (!btn || state.busy || state.generating) {
+ return;
+ }
+ const aspect = btn.getAttribute('data-aspect');
+ const seed = btn.getAttribute('data-seed');
+ const vary = btn.getAttribute('data-vary');
+ const profile = btn.getAttribute('data-krea-profile');
+ if (aspect) {
+ await applyQuickPatch({ aspect, actions: ['generate'] }, `Aspect ${aspect}`);
+ } else if (seed === 'lock') {
+ await applyQuickPatch({ lock_seed: true }, 'Seed locked');
+ } else if (seed === 'random') {
+ await applyQuickPatch({ seed: -1, actions: ['generate'] }, 'Seed random');
+ } else if (vary) {
+ await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary');
+ } else if (profile === 'turbo') {
+ const p = state.kreaProfiles?.turbo || mergedGenerationDefaults('turbo');
+ await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo');
+ } else if (profile === 'raw') {
+ const p = state.kreaProfiles?.raw || mergedGenerationDefaults('raw');
+ await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, sigma_shift: p.sigma_shift, actions: ['generate'] }, 'RAW');
+ }
+ renderLoraChips();
+ });
+ $('sa_auto_vision')?.addEventListener('change', () => {
+ saveSettings();
+ const gen = generateSlot();
+ if (gen) {
+ gen.attach = wantsAutoVision();
+ renderBoard();
+ }
+ });
+ $('sa_auto_apply')?.addEventListener('change', saveSettings);
+ $('sa_auto_generate')?.addEventListener('change', saveSettings);
+ $('sa_auto_critique')?.addEventListener('change', saveSettings);
+ $('sa_auto_download')?.addEventListener('change', saveSettings);
+ $('sa_park_llm')?.addEventListener('change', saveSettings);
+
+ syncChipHighlight();
+ setInterval(syncChipHighlight, 2500);
+ setInterval(renderLoraChips, 4000);
+ syncLiveParamsBar();
+ setInterval(syncLiveParamsBar, 1200);
+ syncModeBadge();
+ syncBuildGenButton();
+
+ setInterval(updateGate, 2000);
+ setInterval(syncGenerateSlot, 700);
+ setInterval(() => {
+ if (!state.busy && !state.generating) {
+ probeOllamaHealth();
+ }
+ }, 45000);
+ setInterval(() => {
+ if (!state.busy && !state.generating) {
+ refreshWantedQueue();
+ }
+ }, 120000);
+ window.addEventListener('beforeunload', () => {
+ try {
+ saveActiveChatToStore({ dropEmpty: true });
+ const chat = findChat(state.activeChatId);
+ if (chat && (chat.messages || []).length) {
+ diskPersist()?.saveChat(chat, { immediate: true });
+ }
+ diskPersist()?.saveUiState(collectUiState(), { immediate: true });
+ } catch (e) { /* ignore */ }
+ });
+ setInterval(() => {
+ if (!state.busy) {
+ const tabOn = !!document.getElementById(TAB_BUTTON_ID)?.classList.contains('tab-button-selected')
+ || !!document.getElementById('swarm_assistent_root')?.offsetParent;
+ refreshInventory(null, { rescan: inventoryIsStale(tabOn ? 45000 : 120000) });
+ }
+ }, 30000);
+
+ window.swarmAssistent = {
+ setImageFromSrc,
+ putImageOnBoard,
+ clearVisionImage,
+ snapshotGenerateToRef,
+ openAssistentTab,
+ sendToAssistent: (src) => {
+ putImageOnBoard(src, { switchTab: true, note: 'Изображение отправлено в Assistent', preferSelected: false });
+ setBoardTab('refs');
+ },
+ isKreaSelected,
+ resolveCurrentCheckpoint,
+ refreshInventory,
+ applyPatch,
+ triggerGenerate,
+ setInitFromSrc,
+ setMaskFromSrc,
+ clearInitAndMask,
+ slotById,
+ renderBoard,
+ setBoardTab,
+ };
+ }
+
+ function wireSlashInput() {
+ const input = $('sa_input');
+ if (!input || input.dataset.saSlashWired) {
+ return;
+ }
+ input.dataset.saSlashWired = '1';
+ input.addEventListener('input', () => updateSlashMenuFromInput());
+ input.addEventListener('keydown', (e) => {
+ const menu = $('sa_slash_menu');
+ const open = menu && !menu.hidden;
+ if (open) {
+ const items = slashMatches((input.value.split(/\s/)[0] || ''));
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ state.slashIndex = Math.min(items.length - 1, (state.slashIndex || 0) + 1);
+ renderSlashMenu(items);
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ state.slashIndex = Math.max(0, (state.slashIndex || 0) - 1);
+ renderSlashMenu(items);
+ return;
+ }
+ if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
+ const pick = items[state.slashIndex || 0];
+ if (pick && input.value.trim() === (input.value.split(/\s/)[0] || '')) {
+ e.preventDefault();
+ applySlashPick(pick);
+ return;
+ }
+ }
+ if (e.key === 'Escape') {
+ hideSlashMenu();
+ return;
+ }
+ }
+ if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
+ e.preventDefault();
+ hideSlashMenu();
+ sendChat();
+ }
+ });
+ input.addEventListener('blur', () => setTimeout(hideSlashMenu, 150));
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', wire);
+ } else {
+ wire();
+ }
+})();
diff --git a/Assets/assistent.patch.js b/Assets/assistent.patch.js
index 9762934..c8650c8 100644
--- a/Assets/assistent.patch.js
+++ b/Assets/assistent.patch.js
@@ -16,6 +16,7 @@ window.SA = window.SA || {};
'clear_prompt_images', 'slot_to_prompt_image', 'pack',
'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes',
+ 'variants',
];
const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
@@ -92,10 +93,53 @@ window.SA = window.SA || {};
return { prose, patch: lastPatch };
}
+ /**
+ * Closed fence worth freezing the stream / stopping Ollama early.
+ * Weak fences (pack / creativity / empty) must NOT stop — model often continues with the real patch.
+ */
+ function isTerminalStreamPatch(obj) {
+ if (!obj || typeof obj !== 'object') {
+ return false;
+ }
+ if (isCardObject(obj)) {
+ return true;
+ }
+ if (Array.isArray(obj.variants) && obj.variants.length) {
+ return true;
+ }
+ if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
+ return true;
+ }
+ if (obj.search_query != null || obj.civitai_query != null
+ || obj.memory_query != null || obj.tag_query != null || obj.inventory_query != null) {
+ return true;
+ }
+ const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
+ const hopOrGen = [
+ 'skill_load', 'persona_read', 'memory_get', 'memory_search', 'lookup_tags',
+ 'list_inventory', 'search_civitai', 'interrupt', 'generate',
+ 'memory_upsert', 'user_pref_upsert',
+ ];
+ if (acts.some((a) => hopOrGen.includes(a))) {
+ return true;
+ }
+ if (String(obj.prompt || '').trim().length >= 48) {
+ return true;
+ }
+ if (obj.loras != null || obj.aspect != null || obj.steps != null
+ || obj.width != null || obj.height != null || obj.cfg != null
+ || obj.seed != null || obj.controls != null
+ || obj.memories != null || obj.user_prefs != null) {
+ return true;
+ }
+ return false;
+ }
+
SA.PATCH_KEYS = PATCH_KEYS;
SA.isCardObject = isCardObject;
SA.isPatchObject = isPatchObject;
+ SA.isTerminalStreamPatch = isTerminalStreamPatch;
SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch;
})();
-
\ No newline at end of file
+
diff --git a/AssistentConfig.cs b/AssistentConfig.cs
index aa2105d..3184bca 100644
--- a/AssistentConfig.cs
+++ b/AssistentConfig.cs
@@ -530,7 +530,8 @@ public sealed class AssistentConfig
}
if (patch["prompt"] != null || patch["loras"] != null || patch["aspect"] != null
|| patch["width"] != null || patch["height"] != null || patch["steps"] != null
- || patch["cfg"] != null || patch["seed"] != null)
+ || patch["cfg"] != null || patch["seed"] != null
+ || patch["variants"] != null)
{
return true;
}
diff --git a/AssistentOllama.cs b/AssistentOllama.cs
index b691bab..0d5600c 100644
--- a/AssistentOllama.cs
+++ b/AssistentOllama.cs
@@ -166,6 +166,12 @@ public partial class SwarmAssistentExtension
{
int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value()
?? DefaultNumCtxFallback;
+ int numPredict = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_predict"]?.Value()
+ ?? 3072;
+ if (numPredict < 512)
+ {
+ numPredict = 512;
+ }
JObject payload = new()
{
["model"] = modelName,
@@ -174,6 +180,8 @@ public partial class SwarmAssistentExtension
["options"] = new JObject
{
["num_ctx"] = numCtx,
+ // Without this, Ollama defaults can cut mid-prompt / mid-skill_load fence.
+ ["num_predict"] = numPredict,
},
["keep_alive"] = "15m",
};
diff --git a/AssistentPatch.cs b/AssistentPatch.cs
index 98dd966..8175f19 100644
--- a/AssistentPatch.cs
+++ b/AssistentPatch.cs
@@ -21,6 +21,7 @@ public partial class SwarmAssistentExtension
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
"memory_query", "memory_kind", "tag_query", "user_prefs",
"inventory_query", "skills", "persona_shelves", "controls",
+ "variants",
];
static bool HasValue(JObject obj, string key)
@@ -107,8 +108,10 @@ public partial class SwarmAssistentExtension
}
///
- /// If the reply already contains a closed fenced patch/card, cut everything after it.
- /// Models often keep writing («Готово!», second aspect, …) and the stream never feels done.
+ /// If the reply already contains a closed fenced patch/card that is "done enough" to act on,
+ /// cut everything after it. Do NOT stop on weak fences (pack/creativity/notes-only) — models
+ /// often emit a tiny JSON first then the real prompt fence; aborting early cuts the prompt
+ /// and blocks skill_load / generate.
///
static bool TryTruncateAtCompleteFence(string reply, out string truncated)
{
@@ -129,18 +132,11 @@ public partial class SwarmAssistentExtension
try
{
JObject obj = JObject.Parse(raw);
- if (obj is null)
- {
- continue;
- }
- bool usable = LooksLikeCardObject(obj)
- || Array.Exists(PatchKeys, k => obj[k] is not null);
- if (!usable)
+ if (obj is null || !FenceIsTerminalPatch(obj))
{
continue;
}
truncated = reply.Substring(0, match.Index + match.Length).TrimEnd();
- // Only treat as complete if the fence actually closed (regex requires ```).
return true;
}
catch
@@ -151,6 +147,77 @@ public partial class SwarmAssistentExtension
return false;
}
+ ///
+ /// True when a closed fence is worth aborting the Ollama stream (real deliverable or tool hop).
+ ///
+ static bool FenceIsTerminalPatch(JObject obj)
+ {
+ if (obj is null)
+ {
+ return false;
+ }
+ if (LooksLikeCardObject(obj))
+ {
+ return true;
+ }
+ if (obj["variants"] is JArray variants && variants.Count > 0)
+ {
+ return true;
+ }
+ if (HasValue(obj, "look_at") || HasValue(obj, "vision_from") || HasValue(obj, "vision_slots"))
+ {
+ return true;
+ }
+ if (HasValue(obj, "search_query") || HasValue(obj, "civitai_query"))
+ {
+ return true;
+ }
+ if (HasValue(obj, "memory_query") || HasValue(obj, "tag_query") || HasValue(obj, "inventory_query"))
+ {
+ return true;
+ }
+ if (obj["actions"] is JArray acts)
+ {
+ foreach (JToken a in acts)
+ {
+ string s = a?.ToString() ?? "";
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ continue;
+ }
+ if (s.Equals("skill_load", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("persona_read", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("memory_get", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("memory_search", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("interrupt", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("generate", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("memory_upsert", StringComparison.OrdinalIgnoreCase)
+ || s.Equals("user_pref_upsert", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ }
+ string prompt = obj["prompt"]?.ToString()?.Trim() ?? "";
+ if (prompt.Length >= 48)
+ {
+ return true;
+ }
+ // Real param change without prose notes
+ if (HasValue(obj, "loras") || HasValue(obj, "aspect") || HasValue(obj, "steps")
+ || HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "cfg")
+ || HasValue(obj, "seed") || HasValue(obj, "controls")
+ || HasValue(obj, "memories") || HasValue(obj, "user_prefs"))
+ {
+ return true;
+ }
+ // Weak: pack / creativity / intensity / empty actions / notes-only → keep streaming
+ return false;
+ }
+
static string ExtractSearchQuery(JObject patch)
{
if (patch is null)
diff --git a/Config/_base/assistant.json b/Config/_base/assistant.json
index 546b7d1..21f9aa9 100644
--- a/Config/_base/assistant.json
+++ b/Config/_base/assistant.json
@@ -1,5 +1,6 @@
{
"num_ctx": 16384,
+ "num_predict": 3072,
"max_civitai_hops": 2,
"max_loras_inventory": 150,
"max_checkpoints_inventory": 60,
@@ -9,6 +10,7 @@
"inventory_prompt_names": 24,
"inventory_hop_limit": 20,
"max_ref_slots": 4,
+ "max_gen_variants": 4,
"default_pack": "ordinary",
"default_persona": "neutral",
"embed_model": "nomic-embed-text",
diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md
index c36a8f6..de373e4 100644
--- a/Config/_base/core/core.md
+++ b/Config/_base/core/core.md
@@ -1,77 +1,92 @@
-# Swarm Assistent — core contract
-
-You are **Swarm Assistent**, a collaborative art director for image generation inside SwarmUI.
-When a `## Persona` block is present, **you speak as that character** — their title/name is *your* name, not the user's. Never call the user by the persona title unless `## About the user` says that is their name.
-
-## Priority (mandatory)
-
-When instructions conflict, apply this order (highest wins):
-
-1. **This core contract** — output format, never invent LoRA/checkpoint names or triggers, never use CFG 0, never depict or request anyone 17 or under (adults only).
-2. **Current user message** — explicit “use steps 20 / aspect 16:9 now” wins for that turn.
-3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
-4. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
-5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
-6. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
-7. **`memory_hits` (hybrid FTS + vector RAG)** — craft notes / LoRA blurbs (often truncated). Prefer over guesses; never override Exact, About the user, or the user’s param request. Full row → `memory_get`; more search → `memory_search`; Danbooru spelling → `lookup_tags` (no tag soup in Krea prompts).
-8. Guesses — last resort only.
-
-Exact = defaults encyclopedia. About the user = human taste. RAG = soft craft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact (or `session_exact`) and the user did not ask to change them.
-
-Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. Keep prose short (a few lines). **After the closing ``` of the JSON fence, STOP** — no «Готово!», no second aspect, no “сейчас сгенерирую оба”. One turn = one patch (one aspect). Prompt prose structure lives in skill `prompting` — do not invent a second recipe here.
-
-## Live context
-
-"Live SwarmUI context" JSON is ground truth for this turn:
-
-- Use only LoRA/checkpoint **names** from `selected_loras` / `available_loras` (or Civitai hop results). `selected_loras` = currently enabled. Prefer listed `triggers` / `trigger_phrase` / `blurb` — **never invent**.
-- Rich entries (blurbs/triggers) are selected + top krea-likely. Name-only rows need `list_inventory` + `inventory_query` before you rely on them.
-- `memory_hits` may be truncated (`truncated: true`) — use `memory_get` for the full text.
-- `has_vision_image` true means a real board frame exists. `images_in_request` true means JPEG bytes are in **this** request. If you need to see a frame and `images_in_request` is false, emit `look_at` first — do not invent what the image looks like.
-- Prefer `krea_likely` / Krea architecture; ignore FLUX/SDXL. Respect current params unless asked or pack is `form_params`.
-- Init/inpaint flags and `image_slots` are in the JSON. Extra pack fields are documented in the active pack.
-
-## Memory (short)
-
-- Craft RAG write: `memory_upsert` / `memory_forget` + `memories: [{kind,key,text,scope}]` (default personal).
-- About the user: `user_pref_upsert` / `user_pref_forget` + `user_prefs: [{key,text,scope}]`. Do **not** put human taste into craft `memories`.
-- Fat memory skill text: `skill_load` + `skills: ["memory"]` when you need the full write/read playbook.
-
-## Output contract (mandatory)
-
-1. Short helpful reply in the user's language (RU or EN).
-2. One fenced JSON patch with **only fields you want to change**:
-
-```json
-{
- "prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
- "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["listed_trigger"]}],
- "aspect": "16:9",
- "actions": ["generate"],
- "notes": "one-line why"
-}
-```
-
-### Patch rules
-
-- Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`.
-- Prefer omitting Exact-matching **`controls`** (e.g. do not re-emit `"horny": 35` / `"preference_bias": 0.35` when unchanged) — echoing defaults in a Generate patch resets the UI sliders.
-- `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height.
-- Optional keys (seed, vary, init/mask, creativity/sliders, pack, **controls**, persona authoring, search/memory queries) — use when needed; packs list the ones for that mode.
-- **`controls`** — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids.
-- Do not invent model or LoRA filenames.
-
-### Actions / hops
-
-- `"generate"` — Apply + start generation when the user wants a new image.
-- If the user only asks to **remember / save** a prompt as base/template («запомни», «как базовый промпт», «шаблон») and did **not** ask for a new image: **omit** `actions: ["generate"]`, do **not** `look_at`. Prefer `memory_upsert` (kind `note`, key like `base_prompt`) or a short ack; you may echo `prompt` in the patch only to sync the SwarmUI box — the UI will not Generate on remember turns.
-- `"search_civitai"` + **required** short `search_query` — Civitai hop (user Confirms downloads). Without `search_query` the hop is skipped (never search the whole user message).
-- `"interrupt"` — stop generation.
-- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
-- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
-- `"skill_load"` + `skills: ["memory"]` — load fat skill text.
-- `"persona_read"` — load lore shelves not in always-on identity (e.g. `roleplay` / `craft` / `humor` when NSFW tone or craft detail needs them).
-- `"memory_upsert"` / `"memory_forget"` / `"user_pref_upsert"` / `"user_pref_forget"` — writes.
-- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — `author_persona` only. Never `"persona_delete"`.
-- `look_at: ["generate"|"ref1"|…]` — vision hop (JPEG arrives on the follow-up).
-- Pure Q&A: omit the JSON patch.
+# Swarm Assistent — core contract
+
+You are **Swarm Assistent**, a collaborative art director for image generation inside SwarmUI.
+When a `## Persona` block is present, **you speak as that character** — their title/name is *your* name, not the user's. Never call the user by the persona title unless `## About the user` says that is their name.
+
+## Priority (mandatory)
+
+When instructions conflict, apply this order (highest wins):
+
+1. **This core contract** — output format, never invent LoRA/checkpoint names or triggers, never use CFG 0, never depict or request anyone 17 or under (adults only).
+2. **Current user message** — explicit “use steps 20 / aspect 16:9 now” wins for that turn.
+3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
+4. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
+5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
+6. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
+7. **`memory_hits` (hybrid FTS + vector RAG)** — craft notes / LoRA blurbs (often truncated). Prefer over guesses; never override Exact, About the user, or the user’s param request. Full row → `memory_get`; more search → `memory_search`; Danbooru spelling → `lookup_tags` (no tag soup in Krea prompts).
+8. Guesses — last resort only.
+
+Exact = defaults encyclopedia. About the user = human taste. RAG = soft craft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact (or `session_exact`) and the user did not ask to change them.
+
+Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. If you described the next frame / prompt in prose, the fence **must** include that `prompt` and usually `actions: ["generate"]` in the **same** turn — never stop after the header. **`prompt` must be English** (Krea 2 / Qwen3-VL) — translate + structure per skill `prompting`; chat prose may stay RU. Keep prose short (a few lines). **After the closing ``` of the JSON fence, STOP** — no «Готово!», no second fenced patch, no “сейчас сгенерирую оба” in prose. **One turn = one patch.** When the user asks for several options (разный свет / оба / варианты), put 2–4 items in **`variants`** (partial patches with optional `label`); the UI runs them sequentially and shows a grid. Do not emit two fences. Ordinary single-image requests stay one patch without `variants`. Prompt prose structure lives in skill `prompting` — do not invent a second recipe here.
+
+## Live context
+
+"Live SwarmUI context" JSON is ground truth for this turn:
+
+- Use only LoRA/checkpoint **names** from `selected_loras` / `available_loras` (or Civitai hop results). `selected_loras` = currently enabled. Prefer listed `triggers` / `trigger_phrase` / `blurb` — **never invent**.
+- Rich entries (blurbs/triggers) are selected + top krea-likely. Name-only rows need `list_inventory` + `inventory_query` before you rely on them.
+- `memory_hits` may be truncated (`truncated: true`) — use `memory_get` for the full text.
+- `has_vision_image` true means a real board frame exists. `images_in_request` true means JPEG bytes are in **this** request. If you need to see a frame and `images_in_request` is false, emit `look_at` first — do not invent what the image looks like.
+- Prefer `krea_likely` / Krea architecture; ignore FLUX/SDXL. Respect current params unless asked or pack is `form_params`.
+- Init/inpaint flags and `image_slots` are in the JSON. Extra pack fields are documented in the active pack.
+
+## Memory (short)
+
+- Craft RAG write: `memory_upsert` / `memory_forget` + `memories: [{kind,key,text,scope}]` (default personal).
+- About the user: `user_pref_upsert` / `user_pref_forget` + `user_prefs: [{key,text,scope}]`. Do **not** put human taste into craft `memories`.
+- Fat memory skill text: `skill_load` + `skills: ["memory"]` when you need the full write/read playbook.
+
+## Output contract (mandatory)
+
+1. Short helpful reply in the user's language (RU or EN).
+2. One fenced JSON patch with **only fields you want to change**:
+
+```json
+{
+ "prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
+ "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["listed_trigger"]}],
+ "aspect": "16:9",
+ "actions": ["generate"],
+ "notes": "one-line why"
+}
+```
+
+Several options in one ask (still one fence):
+
+```json
+{
+ "prompt": "same subject base…",
+ "aspect": "16:9",
+ "actions": ["generate"],
+ "variants": [
+ { "label": "warm light", "prompt": "… warm window light …" },
+ { "label": "cool light", "prompt": "… cool moonlight …" },
+ { "label": "portrait 9:16", "aspect": "9:16" }
+ ]
+}
+```
+
+### Patch rules
+
+- Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`.
+- Prefer omitting Exact-matching **`controls`** (e.g. do not re-emit `"horny": 35` / `"preference_bias": 0.35` when unchanged) — echoing defaults in a Generate patch resets the UI sliders.
+- `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height.
+- Optional keys (seed, vary, init/mask, creativity/sliders, pack, **controls**, persona authoring, search/memory queries, **`variants`**) — use when needed; packs list the ones for that mode.
+- **`controls`** — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids.
+- Do not invent model or LoRA filenames.
+
+### Actions / hops
+
+- `"generate"` — Apply + start generation when the user wants a new image. Auto-Generate is a **UI checkbox** (Settings → Поведение); the model cannot toggle it — emit `actions:["generate"]` instead.
+- If the user only asks to **remember / save** a prompt as base/template («запомни», «как базовый промпт», «шаблон») and did **not** ask for a new image: **omit** `actions: ["generate"]`, do **not** `look_at`. Prefer `memory_upsert` (kind `note`, key like `base_prompt`) or a short ack; you may echo `prompt` in the patch only to sync the SwarmUI box — the UI will not Generate on remember turns.
+- `"search_civitai"` + **required** short `search_query` — Civitai hop (user Confirms downloads). Without `search_query` the hop is skipped (never search the whole user message).
+- `"interrupt"` — stop generation.
+- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
+- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
+- `"skill_load"` + `skills: ["memory"]` — load fat skill text.
+- `"persona_read"` — load lore shelves not in always-on identity (e.g. `roleplay` / `craft` / `humor` when NSFW tone or craft detail needs them).
+- `"memory_upsert"` / `"memory_forget"` / `"user_pref_upsert"` / `"user_pref_forget"` — writes.
+- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — `author_persona` only. Never `"persona_delete"`.
+- `look_at: ["generate"|"ref1"|…]` — vision hop (JPEG arrives on the follow-up).
+- Pure Q&A: omit the JSON patch.
diff --git a/Config/_base/exact.json b/Config/_base/exact.json
index 9e55a90..86d07fe 100644
--- a/Config/_base/exact.json
+++ b/Config/_base/exact.json
@@ -30,6 +30,7 @@
},
"facts": {
"architecture": "Krea 2 is a 12B DiT architecture. Not FLUX, not SDXL, not FLUX.1-Krea. Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE. Use only Krea2-trained LoRAs — never suggest FLUX/SDXL LoRAs.",
+ "prompt_language": "Chat model always prep's Generate prompt for Krea: English natural prose for Qwen3-VL, structured (subject→pose→setting→camera→light). Chat may be RU; never leave Russian or thin drafts in patch.prompt.",
"negatives": "Negative prompts are nearly useless with Qwen3-VL. Prefer positives (sharp focus, empty street) over no blur / no people. Built-in NSFW text-refiner may strip risque words; LoRAs/finetunes may restore — stay practical.",
"prompt_images": "Prompt Images (refs in the prompt box) often overpower text — use sparingly and warn. Init Image = structure (img2img). Mask = local fix. They are not interchangeable. Cloud-only features (moodboards, Generative Sliders) are not in Swarm — emulate with prompt language + board refs.",
"turbo": "Krea 2 Turbo defaults live in exact.generation / exact.profiles.turbo. Never use CFG 0 — broken output. Side ~1024 (128–4096 OK).",
diff --git a/Config/_base/packs/ordinary.md b/Config/_base/packs/ordinary.md
index 412420c..84d8f5e 100644
--- a/Config/_base/packs/ordinary.md
+++ b/Config/_base/packs/ordinary.md
@@ -27,3 +27,5 @@ Otherwise **stay in ordinary** and just do the work.
## Deliverable
Same as write_prompt: short reply + one fenced JSON patch; `actions: ["generate"]` when they want an image.
+«давай дальше» / next frame = new English `prompt` + `actions:["generate"]` in the **same** turn — never leave an empty `### JSON Patch` header. Chat may be RU; **Generate `prompt` is always EN** (skill `prompting`).
+Several options in one ask → `variants` (2–4 partial patches with `label`); still one fence, still STOP after it.
diff --git a/Config/_base/packs/write_prompt.md b/Config/_base/packs/write_prompt.md
index 0e4f307..e6a7384 100644
--- a/Config/_base/packs/write_prompt.md
+++ b/Config/_base/packs/write_prompt.md
@@ -1,6 +1,6 @@
# Mode: write_prompt
-Goal: craft or improve a **Krea 2** prompt that will generate well on Turbo (local Swarm). Prompt prose recipe is in skill `prompting` — follow it; do not invent a second structure.
+Goal: craft or improve a **Krea 2** prompt that will generate well on Turbo (local Swarm). Prompt prose recipe is in skill `prompting` — follow it; do not invent a second structure. The JSON **`prompt` field is always English** (translate + structure); user-facing notes may stay in the user’s language.
## Deliverable
@@ -9,6 +9,7 @@ Goal: craft or improve a **Krea 2** prompt that will generate well on Turbo (loc
- `actions: ["generate"]` when the user wants a new image — UI applies + Generate without Apply buttons.
- Prefer Exact Turbo defaults / `recommended_params`. Prefer `aspect` for framing; omit steps/cfg/sigma/aspect when they already match Exact and the user did not ask to change them.
- Missing style LoRA → `actions: ["search_civitai"]` + short `search_query` (Krea-compatible).
+- User wants several options (оба / варианты / разный свет) → `variants: [{label, prompt|aspect|…}, …]` (2–4). Base keys inherit; each item overrides only its diffs. Still one fence.
### Bad → good
diff --git a/Config/_base/rules.json b/Config/_base/rules.json
index 6f6b84f..182910b 100644
--- a/Config/_base/rules.json
+++ b/Config/_base/rules.json
@@ -1,6 +1,6 @@
{
"always": [
- "Match the user's language (RU or EN)",
+ "Match the user's language (RU or EN) in chat — Generate prompt stays English",
"Use only inventory / memory_hits / cards for LoRA names and triggers",
"Emit valid JSON patches when changing generation state"
],
diff --git a/Config/_base/skills/prompting.md b/Config/_base/skills/prompting.md
index 309ea23..30caf38 100644
--- a/Config/_base/skills/prompting.md
+++ b/Config/_base/skills/prompting.md
@@ -1,9 +1,27 @@
# Skill: prompting
-Write **natural prose** for a photographer/director — not Danbooru tags, not `(word:1.5)`, not `masterpiece / best quality / 8k`.
+The **chat model** prepares the Generate-box text for **Krea 2** (Qwen3-VL). Do this prep as fully as you can in the same turn (or the dedicated prep hop). Chat with the user may be RU/EN; the JSON **`prompt` is always English**.
-Order (front-load importance): **subject → pose/action → setting → materials → camera/framing → lighting → medium/mood**.
+## Hard rules (Krea / Qwen3-VL)
-- Short user ideas: expand. Finished Krea-style paragraphs: keep wording; only fix anti-patterns.
-- Put LoRA trigger phrases near the subject they affect.
-- Prefer positives over negatives.
+1. **`prompt` language = English only** — never put Russian into the JSON `prompt`. Translate the idea, then structure it.
+2. **Natural photographer/director prose** — not Danbooru tag soup, not `(word:1.5)`, not `masterpiece / best quality / 8k`.
+3. **Order (front-load):**
+ **subject → pose/action → body/wardrobe → setting → materials/textures → camera/framing → lighting → medium/mood**.
+4. Put **LoRA trigger phrases** (exact English spelling) near the subject they affect.
+5. Prefer **positives** over negatives (Qwen negatives are weak).
+6. Short ideas: expand into a full Krea paragraph. Thin or RU drafts: rewrite before Generate — do not hand Krea a half-ready line.
+
+## Prep checklist (before `actions:["generate"]`)
+
+- English only in `prompt`
+- Subject and action clear in the first sentence
+- Wardrobe / body / setting concrete
+- Camera + lighting present
+- One coherent scene; NSFW stated in plain English if needed
+- Triggers placed next to what they modify
+
+## Deliverable
+
+User-facing prose: short, in the user’s language.
+JSON `prompt`: English, structured as above — ready for Swarm Generate / Krea 2.
diff --git a/Config/_base/ui.json b/Config/_base/ui.json
index 700d1a2..b01b531 100644
--- a/Config/_base/ui.json
+++ b/Config/_base/ui.json
@@ -1,6 +1,6 @@
{
- "welcome_html": "Assistent · Krea 2
Generate слева — живой просмотр. В чат сам не уходит.Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.Галочка vision на окне — отправить кадр модели. Чипсы aspect / seed / Vary / Turbo·RAW. В чате: /help. Кнопки патча только у последнего предложения. Напиши, что сгенерировать — или кинь референс и попроси правку.",
- "help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\n/gen — Generate сейчас\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|ordinary|critique|compose|params|inpaint|describe|card|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone — клон с указанной\n/persona save — записать согласованный черновик\n/civitai — поиск LoRA (Confirm в чате)\n/inventory — rescan моделей + обновить список LoRA\n\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nПри старте всегда новый чат; смена чата восстанавливает параметры.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).",
+ "welcome_html": "Assistent · Krea 2
Generate слева — живой просмотр. Несколько вариантов → сетка + клик для просмотра.Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.Галочка vision на окне — отправить кадр модели. Чипсы aspect / seed / Vary / Turbo·RAW. В чате: /help. Кнопки патча только у последнего предложения. Напиши, что сгенерировать — или кинь референс и попроси правку.",
+ "help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\n/gen — Generate сейчас\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|ordinary|critique|compose|params|inpaint|describe|card|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone — клон с указанной\n/persona save — записать согласованный черновик\n/civitai — поиск LoRA (Confirm в чате)\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate, клик / Открыть / Enter — просмотр.\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nПри старте всегда новый чат; смена чата восстанавливает параметры.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).",
"chips": [
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
diff --git a/Config/personas/leonid/craft.json b/Config/personas/leonid/craft.json
index b4ead23..79438d6 100644
--- a/Config/personas/leonid/craft.json
+++ b/Config/personas/leonid/craft.json
@@ -6,9 +6,10 @@
"detailed Krea prose"
],
"process": [
+ "translate idea → English Krea prose before Generate",
"subject → pose → clothes/hair → setting → camera → light → mood",
"front-load what matters",
- "natural prose, not tag soup"
+ "natural EN prose, not tag soup"
],
"defaults": [
"photoreal / film unless user asks anime/hentai",
diff --git a/Config/personas/leonid/rules.json b/Config/personas/leonid/rules.json
index 2e4a227..d92b304 100644
--- a/Config/personas/leonid/rules.json
+++ b/Config/personas/leonid/rules.json
@@ -2,7 +2,7 @@
"always": [
"You are Leonid; the user is not Leonid — never greet/address them as Леонид/Leonid unless About the user says their name is that",
"If asked your name, say you are Leonid (assistant)",
- "Match user language (RU/EN)",
+ "Match user language (RU/EN) in chat; Generate patch prompt is always English for Krea",
"Craft first: triggers, aspect, Turbo — horny never replaces technique",
"Scale appearance/outfits by controls.preference_bias (−1…1)",
"Scale sexual tone + roleplay fetishes by controls.horny (0…100)",
diff --git a/README.md b/README.md
index 6f1c2cd..b57bbe8 100644
--- a/README.md
+++ b/README.md
@@ -1,173 +1,183 @@
-# Swarm Assistent
-
-SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
-
-**Version 0.10.21** — Board Generate spinner clears when Swarm finishes (`num_live_gens`), not only when the image URL string changes. Builds on 0.10.20 remember-without-gen.
-
-**Version 0.10.20** — «Запомни / базовый промпт» no longer triggers Auto-Generate or auto look_at (even if the model sneaks `actions:["generate"]`). Builds on 0.10.19 slider echo fix.
-
-**Version 0.10.19** — Вкус/Хорни no longer reset: Generate patches never apply `controls`; default-echo filtered always; bare «вкус» no longer disables the filter. Builds on 0.10.18 settings tab.
-
-**Version 0.10.18** — Settings is a full subtab (Чат | Карточки | Настройки), not a header overlay. Builds on 0.10.17 post-Generate warm.
-
-## Layout
-
-- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`); **Посмотри результат** attaches the finished frame and asks for a verdict
-- **Splitter:** drag to resize panes
-- **Right:** Chat | Cards | Settings; persona / pack / Ollama chat model; **Ollama health** badge
-- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
-
-## Config (bundled + overlay)
-
-```
-Config/
- _base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
- personas// # sparse shelves: persona/bio/voice/humor/… + optional controls.json / exact.json / memory-seed
-```
-
-Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder layout as `Config/`. Drop `_base/…` and `personas//…` to override. Plus runtime state:
-
-```
-Assistent/
- _base/ personas// # overlay presets — same names as Config/, sparse
- settings.json # embed_model, base_url, per-persona skills
- ollama-roles.json # chat vs memory model tags (gpu-rent writes this)
- memory/assistent.sqlite # craft RAG + user_prefs + tags FTS + chats + ui_state + taste (legacy)
- _migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json
-```
-
-Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/personas/README.md`.
-
-**Controls:** optional `controls.json` schema + `exact.controls` values. UI auto-draws every slider (`order`, `display: percent`). LLM may patch `"controls": {…}`. Values persist in overlay Exact (DeepMerge partial saves). Leonid: **Вкус** + **Хорни**; `/остынь`, `/horny-game`.
-
-**Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button or ⚙ → Личности (never from the model). Export/import `.assistent-persona.json` for sharing.
-
-## Exact memory (KV)
-
-- `Config/_base/exact.json` — canonical generation defaults, profiles (turbo/raw), aspect table, short facts
-- Persona / disk overlays merge via DeepMerge (matching keys overwrite)
-- Always injected into the system prompt; UI fills **empty** SwarmUI fields from Exact (no LLM call)
-- Chat-session overrides (`session_exact`) last until persona change or clear chat — not written to disk
-- Priority: core → user message → **About the user** → session_exact → exact (+ persona) → live fields → craft `memory_hits`
-
-## About the user (UserPrefs)
-
-Separate sqlite table `user_prefs` (not craft RAG):
-
-- **Global** — every persona (e.g. “avoid blonde hair”)
-- **Persona** — only the current agent
-- Injected as `## About the user`; strength via `user_prefs_weight` / `user_prefs_max` in `assistant.json` (⚙ → О пользователе)
-- Agent write: `actions: ["user_pref_upsert"]` + `user_prefs: [{key,text,scope}]`
-- Legacy `kv.taste` migrates once into global prefs
-
-## Craft vector memory
-
-Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
-
-- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
-- **Personal** — `Config/personas//memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
-- Retrieve = shared ∪ this persona (and `extends` parents). Hybrid **FTS5 + cosine**, kind quotas, `min_score`. Same `kind`+`key`: personal overwrites parent overwrites shared.
-- Tools: `memory_get`, `memory_search`, `lookup_tags` (Danbooru csv in `Data/Autocompletions`, FTS, **no embeddings**).
-- Soft craft notes only — Exact, About the user, and the user beat RAG for params
-- ⚙ → **Крафт** lists rows with filters + clear (bundled seed is read-only)
-
-## Chats and runtime KV
-
-- Every chat (messages + Generate params snapshot) is a row in `assistent.sqlite`, newest **200** kept. History search uses FTS over title + body.
-- First launch after 0.8.3 copies `chats/*.json`, `ui-state.json`, and `taste.json` into sqlite, then archives them under `_migrated_json/`.
-- localStorage stays as a fast cache; on first run with an empty store the old `swarm_assistent_chats_v1` browser history is migrated up once.
-- UI state seeds a **fresh** browser only — anything already in localStorage wins, and `auto_download` is never restored as on
-- `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights.
-## VRAM handover
-
-- Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default off
-- After Generate the chat model is **always** force-warmed (`AssistentWarmLlm`) — Krea still often evicts VL from VRAM even without park
-- Embed / memory models are never parked — reloading them would stall every retrieve
-
-## UX
-
-- **Send to Assistent** under Generate/History → Ref + Assistent tab
-- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
-- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
-- **Посмотри результат** / auto-critique wait for a real Generate frame — model previews and unfinished batches are skipped
-- Civitai Confirm required (unless auto-download); queued-but-missing models show a `⏳ wanted` badge in Cards
-
-### Slash commands (client-side, no LLM)
-
-| Command | Effect |
-| --- | --- |
-| `/help` | List commands |
-| `/debug` | Short UI/Exact dump (no LLM) |
-| `/debug ask` / `/why` | Dump + short model explanation |
-| `/gen` | Generate now |
-| `/look generate\|refN` | Attach that board window + ask the LLM to look |
-| `/init` `/mask` `/clear` | Same as board buttons |
-| `/interrupt` | Stop generation / cancel chat |
-| `/aspect 16:9` | Set size from the official 1K table |
-| `/seed lock\|random` | Lock or randomize seed |
-| `/vary` | New seed, same prompt (+ generate if auto) |
-| `/pack write\|critique\|…` | Switch pack |
-| `/civitai ` | Ask LLM to search Civitai |
-| `/inventory` | Rescan models + refresh LoRA list |
-
-## Requirements
-
-- SwarmUI with a **Krea 2** checkpoint selected
-- Ollama on `http://127.0.0.1:11434` **on the GPU VM** (gpu-rent `LLM_RUNTIME=ollama`)
-- Chat model + memory embed (`use: memory` in `ollama-models.yaml`; gpu-rent creates CPU variant)
-- Optional: Civitai API key in SwarmUI User Settings
-
-## Install
-
-```yaml
-swarmui:
- - url: https://gitea.hsrv.site/mrleo1nid/swarm-assistent.git
- ref: main
- dir: swarm-assistent
- requires: ollama
-```
-
-Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
-
-## Packs & skills
-
-**Packs** (one active): `ordinary` (default комбайн), `write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`, `author_persona`.
-
-**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures; encyclopedia numbers live in Exact, soft notes in memory-seed / RAG, human taste in UserPrefs.
-
-**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`, `leonid` under `Config/personas/`. Overlay clones via `/persona new` or ⚙ → Личности.
-
-## API routes
-
-| Route | Role |
-| --- | --- |
-| `AssistentListModels` | Ollama tags → `models` (chat) + `memory_models` |
-| `AssistentGetConfig` | Merged preset for persona (ui, packs, skills, identity, controls) |
-| `AssistentSaveControls` | Persist Exact `controls` values for a persona (overlay) |
-| `AssistentGetPersonaShelves` | Merged identity shelves + controls |
-| `AssistentClonePersona` | Snapshot clone → overlay id |
-| `AssistentSavePersona` | Sparse shelf write (overlay only) |
-| `AssistentDeletePersona` | UI-only delete of overlay persona |
-| `AssistentExportPersona` / `AssistentImportPersona` | Shareable `.assistent-persona.json` pack |
-| `AssistentSaveKnobs` | Overlay `_base/assistant.json` + Exact turbo/raw profiles |
-| `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) |
-| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
-| `AssistentListPersonas` | Persona catalog |
-| `AssistentGetPacks` | Prompt pack texts |
-| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
-| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
-| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
-| `AssistentGetTaste` / `AssistentSaveTaste` | sqlite `kv.taste` (legacy; prefer UserPrefs) |
-| `AssistentListUserPrefs` / `AssistentUpsertUserPref` / `AssistentForgetUserPref` / `AssistentClearUserPrefs` | About the user |
-| `AssistentSearchCivitai` | Civitai LoRA search |
-| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + hops) |
-| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
-| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
-| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
-| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
-| `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` |
-| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
-
-## License
-
-MIT
+# Swarm Assistent
+
+SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
+
+**Version 0.11.3** — Stream no longer aborts on weak JSON fences (so prompts/skill_load can finish); `num_predict` 3072. Builds on 0.11.2 Krea prep.
+
+**Version 0.11.2** — Before Krea Generate, chat model maximally preps the prompt (EN + structure); skip prep hop only if already Krea-ready English. Builds on 0.11.1.
+
+**Version 0.11.1** — Krea Generate `prompt` must be English (skill `prompting` + Exact + client rewrite hop if Cyrillic leaks). Builds on 0.11.0 variants.
+
+**Version 0.11.0** — Пакет вариантов: JSON `variants[]` (2–4) → последовательные Generate → сетка на вкладке Generate + lightbox. Builds on 0.10.22 empty-patch fix.
+
+**Version 0.10.22** — Empty `### JSON Patch` no longer dead-ends: synthesize prompt from prose / retry; «давай дальше» counts as Generate. Builds on 0.10.21 spinner fix.
+
+**Version 0.10.21** — Board Generate spinner clears when Swarm finishes (`num_live_gens`), not only when the image URL string changes. Builds on 0.10.20 remember-without-gen.
+
+**Version 0.10.20** — «Запомни / базовый промпт» no longer triggers Auto-Generate or auto look_at (even if the model sneaks `actions:["generate"]`). Builds on 0.10.19 slider echo fix.
+
+**Version 0.10.19** — Вкус/Хорни no longer reset: Generate patches never apply `controls`; default-echo filtered always; bare «вкус» no longer disables the filter. Builds on 0.10.18 settings tab.
+
+**Version 0.10.18** — Settings is a full subtab (Чат | Карточки | Настройки), not a header overlay. Builds on 0.10.17 post-Generate warm.
+
+## Layout
+
+- **Left — Board tabs:** **Generate** (live view, or a **variant grid** when the model emits `variants[]`) | **Refs** (reference grid + badge `N · vision M`); click a variant to select / open lightbox; **Посмотри результат** attaches the selected finished frame and asks for a verdict
+- **Splitter:** drag to resize panes
+- **Right:** Chat | Cards | Settings; persona / pack / Ollama chat model; **Ollama health** badge
+- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
+
+## Config (bundled + overlay)
+
+```
+Config/
+ _base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
+ personas// # sparse shelves: persona/bio/voice/humor/… + optional controls.json / exact.json / memory-seed
+```
+
+Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder layout as `Config/`. Drop `_base/…` and `personas//…` to override. Plus runtime state:
+
+```
+Assistent/
+ _base/ personas// # overlay presets — same names as Config/, sparse
+ settings.json # embed_model, base_url, per-persona skills
+ ollama-roles.json # chat vs memory model tags (gpu-rent writes this)
+ memory/assistent.sqlite # craft RAG + user_prefs + tags FTS + chats + ui_state + taste (legacy)
+ _migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json
+```
+
+Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/personas/README.md`.
+
+**Controls:** optional `controls.json` schema + `exact.controls` values. UI auto-draws every slider (`order`, `display: percent`). LLM may patch `"controls": {…}`. Values persist in overlay Exact (DeepMerge partial saves). Leonid: **Вкус** + **Хорни**; `/остынь`, `/horny-game`.
+
+**Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button or ⚙ → Личности (never from the model). Export/import `.assistent-persona.json` for sharing.
+
+## Exact memory (KV)
+
+- `Config/_base/exact.json` — canonical generation defaults, profiles (turbo/raw), aspect table, short facts
+- Persona / disk overlays merge via DeepMerge (matching keys overwrite)
+- Always injected into the system prompt; UI fills **empty** SwarmUI fields from Exact (no LLM call)
+- Chat-session overrides (`session_exact`) last until persona change or clear chat — not written to disk
+- Priority: core → user message → **About the user** → session_exact → exact (+ persona) → live fields → craft `memory_hits`
+
+## About the user (UserPrefs)
+
+Separate sqlite table `user_prefs` (not craft RAG):
+
+- **Global** — every persona (e.g. “avoid blonde hair”)
+- **Persona** — only the current agent
+- Injected as `## About the user`; strength via `user_prefs_weight` / `user_prefs_max` in `assistant.json` (⚙ → О пользователе)
+- Agent write: `actions: ["user_pref_upsert"]` + `user_prefs: [{key,text,scope}]`
+- Legacy `kv.taste` migrates once into global prefs
+
+## Craft vector memory
+
+Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
+
+- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
+- **Personal** — `Config/personas//memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
+- Retrieve = shared ∪ this persona (and `extends` parents). Hybrid **FTS5 + cosine**, kind quotas, `min_score`. Same `kind`+`key`: personal overwrites parent overwrites shared.
+- Tools: `memory_get`, `memory_search`, `lookup_tags` (Danbooru csv in `Data/Autocompletions`, FTS, **no embeddings**).
+- Soft craft notes only — Exact, About the user, and the user beat RAG for params
+- ⚙ → **Крафт** lists rows with filters + clear (bundled seed is read-only)
+
+## Chats and runtime KV
+
+- Every chat (messages + Generate params snapshot) is a row in `assistent.sqlite`, newest **200** kept. History search uses FTS over title + body.
+- First launch after 0.8.3 copies `chats/*.json`, `ui-state.json`, and `taste.json` into sqlite, then archives them under `_migrated_json/`.
+- localStorage stays as a fast cache; on first run with an empty store the old `swarm_assistent_chats_v1` browser history is migrated up once.
+- UI state seeds a **fresh** browser only — anything already in localStorage wins, and `auto_download` is never restored as on
+- `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights.
+## VRAM handover
+
+- Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default off
+- After Generate the chat model is **always** force-warmed (`AssistentWarmLlm`) — Krea still often evicts VL from VRAM even without park
+- Embed / memory models are never parked — reloading them would stall every retrieve
+
+## UX
+
+- **Send to Assistent** under Generate/History → Ref + Assistent tab
+- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
+- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
+- **Посмотри результат** / auto-critique wait for a real Generate frame — model previews and unfinished batches are skipped
+- Civitai Confirm required (unless auto-download); queued-but-missing models show a `⏳ wanted` badge in Cards
+
+### Slash commands (client-side, no LLM)
+
+| Command | Effect |
+| --- | --- |
+| `/help` | List commands |
+| `/debug` | Short UI/Exact dump (no LLM) |
+| `/debug ask` / `/why` | Dump + short model explanation |
+| `/gen` | Generate now |
+| `/look generate\|refN` | Attach that board window + ask the LLM to look |
+| `/init` `/mask` `/clear` | Same as board buttons |
+| `/interrupt` | Stop generation / cancel chat |
+| `/aspect 16:9` | Set size from the official 1K table |
+| `/seed lock\|random` | Lock or randomize seed |
+| `/vary` | New seed, same prompt (+ generate if auto) |
+| `/pack write\|critique\|…` | Switch pack |
+| `/civitai ` | Ask LLM to search Civitai |
+| `/inventory` | Rescan models + refresh LoRA list |
+
+## Requirements
+
+- SwarmUI with a **Krea 2** checkpoint selected
+- Ollama on `http://127.0.0.1:11434` **on the GPU VM** (gpu-rent `LLM_RUNTIME=ollama`)
+- Chat model + memory embed (`use: memory` in `ollama-models.yaml`; gpu-rent creates CPU variant)
+- Optional: Civitai API key in SwarmUI User Settings
+
+## Install
+
+```yaml
+swarmui:
+ - url: https://gitea.hsrv.site/mrleo1nid/swarm-assistent.git
+ ref: main
+ dir: swarm-assistent
+ requires: ollama
+```
+
+Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
+
+## Packs & skills
+
+**Packs** (one active): `ordinary` (default комбайн), `write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`, `author_persona`.
+
+**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures; encyclopedia numbers live in Exact, soft notes in memory-seed / RAG, human taste in UserPrefs.
+
+**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`, `leonid` under `Config/personas/`. Overlay clones via `/persona new` or ⚙ → Личности.
+
+## API routes
+
+| Route | Role |
+| --- | --- |
+| `AssistentListModels` | Ollama tags → `models` (chat) + `memory_models` |
+| `AssistentGetConfig` | Merged preset for persona (ui, packs, skills, identity, controls) |
+| `AssistentSaveControls` | Persist Exact `controls` values for a persona (overlay) |
+| `AssistentGetPersonaShelves` | Merged identity shelves + controls |
+| `AssistentClonePersona` | Snapshot clone → overlay id |
+| `AssistentSavePersona` | Sparse shelf write (overlay only) |
+| `AssistentDeletePersona` | UI-only delete of overlay persona |
+| `AssistentExportPersona` / `AssistentImportPersona` | Shareable `.assistent-persona.json` pack |
+| `AssistentSaveKnobs` | Overlay `_base/assistant.json` + Exact turbo/raw profiles |
+| `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) |
+| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
+| `AssistentListPersonas` | Persona catalog |
+| `AssistentGetPacks` | Prompt pack texts |
+| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
+| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
+| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
+| `AssistentGetTaste` / `AssistentSaveTaste` | sqlite `kv.taste` (legacy; prefer UserPrefs) |
+| `AssistentListUserPrefs` / `AssistentUpsertUserPref` / `AssistentForgetUserPref` / `AssistentClearUserPrefs` | About the user |
+| `AssistentSearchCivitai` | Civitai LoRA search |
+| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + hops) |
+| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
+| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
+| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
+| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
+| `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` |
+| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
+
+## License
+
+MIT
diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs
index 62597a6..39baa05 100644
--- a/SwarmAssistentExtension.cs
+++ b/SwarmAssistentExtension.cs
@@ -1,274 +1,274 @@
-using System;
-using System.IO;
-using System.Text.RegularExpressions;
-using System.Threading.Tasks;
-using Newtonsoft.Json.Linq;
-using SwarmUI.Accounts;
-using SwarmUI.Core;
-using SwarmUI.Utils;
-using SwarmUI.WebAPI;
-using System.Net.Http;
-
-namespace Mrleo1nid.SwarmAssistent;
-
-/// Krea 2 collaborative assistant: Ollama chat + vision + prompt/LoRA/params patches + Generate/Civitai.
-public partial class SwarmAssistentExtension : Extension
-{
- public static PermInfo PermUse = Permissions.Register(new(
- "swarm_assistent_use",
- "[Swarm Assistent] Use",
- "Allows using the Swarm Assistent chat (Ollama proxy).",
- PermissionDefault.USER,
- Permissions.GroupUser));
-
- public static HttpClient HttpClient;
-
- public AssistentConfig Config;
- public AssistentMemory Memory;
-
- public override void OnPreInit()
- {
- ScriptFiles.Add("Assets/assistent.api.js");
- ScriptFiles.Add("Assets/assistent.patch.js");
- ScriptFiles.Add("Assets/assistent.persist.js");
- ScriptFiles.Add("Assets/assistent.js");
- StyleSheetFiles.Add("Assets/assistent.css");
- ExtensionAuthor = "mrleo1nid";
- Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
- License = "MIT";
- Version = "0.10.21";
- Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
- }
-
- public override void OnInit()
- {
- HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
- Config = new AssistentConfig(FilePath, DataRoot());
- Memory = new AssistentMemory(DataRoot(), HttpClient, Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text");
- API.RegisterAPICall(AssistentListModels, false, PermUse);
- API.RegisterAPICall(AssistentGetPacks, false, PermUse);
- API.RegisterAPICall(AssistentListPersonas, false, PermUse);
- API.RegisterAPICall(AssistentGetConfig, false, PermUse);
- API.RegisterAPICall(AssistentGetSettings, false, PermUse);
- API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
- API.RegisterAPICall(AssistentListInventory, false, PermUse);
- API.RegisterAPICall(AssistentGetCard, false, PermUse);
- API.RegisterAPICall(AssistentSaveCard, true, PermUse);
- API.RegisterAPICall(AssistentEnqueueWanted, true, PermUse);
- API.RegisterAPICall(AssistentGetCardMeta, false, PermUse);
- API.RegisterAPICall(AssistentSearchCivitai, false, PermUse);
- API.RegisterAPICall(AssistentGetTaste, false, PermUse);
- API.RegisterAPICall(AssistentSaveTaste, true, PermUse);
- API.RegisterAPICall(AssistentChat, true, PermUse);
- API.RegisterAPICall(AssistentChatWS, true, PermUse);
- API.RegisterAPICall(AssistentListChats, false, PermUse);
- API.RegisterAPICall(AssistentGetChat, false, PermUse);
- API.RegisterAPICall(AssistentSaveChat, true, PermUse);
- API.RegisterAPICall(AssistentDeleteChat, true, PermUse);
- API.RegisterAPICall(AssistentGetUiState, false, PermUse);
- API.RegisterAPICall(AssistentSaveUiState, true, PermUse);
- API.RegisterAPICall(AssistentParkLlm, true, PermUse);
- API.RegisterAPICall(AssistentWarmLlm, true, PermUse);
- API.RegisterAPICall(AssistentListMemory, false, PermUse);
- API.RegisterAPICall(AssistentUpsertMemory, true, PermUse);
- API.RegisterAPICall(AssistentForgetMemory, true, PermUse);
- API.RegisterAPICall(AssistentSearchMemory, false, PermUse);
- API.RegisterAPICall(AssistentGetMemory, false, PermUse);
- API.RegisterAPICall(AssistentLookupTags, false, PermUse);
- API.RegisterAPICall(AssistentListWanted, false, PermUse);
- API.RegisterAPICall(AssistentSaveControls, true, PermUse);
- API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
- API.RegisterAPICall(AssistentClonePersona, true, PermUse);
- API.RegisterAPICall(AssistentSavePersona, true, PermUse);
- API.RegisterAPICall(AssistentDeletePersona, true, PermUse);
- API.RegisterAPICall(AssistentExportPersona, false, PermUse);
- API.RegisterAPICall(AssistentImportPersona, true, PermUse);
- API.RegisterAPICall(AssistentSaveKnobs, true, PermUse);
- API.RegisterAPICall(AssistentListUserPrefs, false, PermUse);
- API.RegisterAPICall(AssistentUpsertUserPref, true, PermUse);
- API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
- API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
- API.RegisterAPICall(AssistentClearMemory, true, PermUse);
- Logs.Init("Swarm Assistent extension loaded (settings panel + user prefs + craft memory)");
- }
-
- int CfgInt(string key, int fallback)
- {
- try
- {
- return Config?.LoadAssistant(Config.DefaultPersonaId())[key]?.Value() ?? fallback;
- }
- catch
- {
- return fallback;
- }
- }
-
- static string Clip(string text, int max)
- {
- if (string.IsNullOrEmpty(text) || text.Length <= max)
- {
- return text ?? "";
- }
- return text[..max] + "…";
- }
-
- static string CollapseWs(string text)
- {
- if (string.IsNullOrWhiteSpace(text))
- {
- return "";
- }
- return Regex.Replace(text.Trim(), @"\s+", " ");
- }
-
- public static string NormalizeBaseUrl(string raw)
- {
- string url = (raw ?? "").Trim();
- if (string.IsNullOrWhiteSpace(url))
- {
- url = "http://127.0.0.1:11434";
- }
- return url.TrimEnd('/');
- }
-
- static string DataRoot()
- {
- if (Directory.Exists("/mnt/swarm_data"))
- {
- return "/mnt/swarm_data";
- }
- try
- {
- string models = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "Models"));
- if (Directory.Exists(models))
- {
- return Path.GetDirectoryName(models) ?? Environment.CurrentDirectory;
- }
- }
- catch
- {
- // ignore
- }
- return Environment.CurrentDirectory;
- }
-
- public string ReadPackFile(string name)
- {
- return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name);
- }
-
- public async Task AssistentGetPacks(Session session, string persona = null)
- {
- await Task.CompletedTask;
- string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
- JObject packs = new();
- JArray order = [];
- foreach (var p in Config.ListPacks(pid))
- {
- string text = Config.LoadPackPrompt(pid, p.id);
- if (text is not null)
- {
- packs[p.id] = text;
- }
- order.Add(p.id);
- }
- return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = order, ["persona"] = pid };
- }
-
- public async Task AssistentGetConfig(Session session, string persona = null)
- {
- await Task.CompletedTask;
- string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
- return Config.BuildMergedConfigPayload(pid);
- }
-
- public async Task AssistentGetSettings(Session session)
- {
- await Task.CompletedTask;
- return new JObject { ["success"] = true, ["settings"] = Config.LoadSettings() };
- }
-
- public async Task AssistentSaveSettings(Session session, JObject settings)
- {
- await Task.CompletedTask;
- if (settings is null)
- {
- return new JObject { ["error"] = "settings required" };
- }
- string prevEmbed = Config.LoadSettings()["embed_model"]?.ToString();
- Config.SaveSettings(settings);
- string nextEmbed = settings["embed_model"]?.ToString();
- if (!string.IsNullOrWhiteSpace(nextEmbed) && !string.Equals(prevEmbed, nextEmbed, StringComparison.OrdinalIgnoreCase))
- {
- try
- {
- await Memory.ReembedAllAsync(NormalizeBaseUrl(settings["base_url"]?.ToString()), nextEmbed);
- }
- catch (Exception ex)
- {
- Logs.Debug($"AssistentSaveSettings reembed: {ex.Message}");
- }
- }
- return new JObject { ["success"] = true, ["path"] = Path.Combine(Config.OverlayRoot, "settings.json") };
- }
-
- public async Task AssistentListPersonas(Session session)
- {
- await Task.CompletedTask;
- var catalog = Config.ListPersonaCatalog();
- JArray list = [];
- foreach (var p in catalog)
- {
- list.Add(new JObject
- {
- ["id"] = p.id,
- ["title"] = p.title,
- ["accent"] = p.accent,
- ["prompt"] = Config.RenderIdentityBlock(p.id, includeAllShelves: true),
- ["source"] = p.source,
- });
- }
- return new JObject
- {
- ["success"] = true,
- ["default"] = Config.DefaultPersonaId(),
- ["personas"] = list,
- };
- }
-
- public async Task AssistentGetTaste(Session session)
- {
- await Task.CompletedTask;
- try
- {
- return new JObject { ["success"] = true, ["taste"] = Memory.GetKvObject(AssistentMemory.KvTaste) };
- }
- catch (Exception ex)
- {
- return new JObject { ["error"] = $"taste: {ex.Message}" };
- }
- }
-
- public async Task AssistentSaveTaste(Session session, JObject taste)
- {
- await Task.CompletedTask;
- if (taste is null)
- {
- return new JObject { ["error"] = "taste required" };
- }
- if (taste["updated"] == null)
- {
- taste["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
- }
- try
- {
- Memory.SetKvObject(AssistentMemory.KvTaste, taste);
- return new JObject { ["success"] = true, ["path"] = "Assistent/memory/assistent.sqlite" };
- }
- catch (Exception ex)
- {
- return new JObject { ["error"] = $"taste save: {ex.Message}" };
- }
- }
-}
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Core;
+using SwarmUI.Utils;
+using SwarmUI.WebAPI;
+using System.Net.Http;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Krea 2 collaborative assistant: Ollama chat + vision + prompt/LoRA/params patches + Generate/Civitai.
+public partial class SwarmAssistentExtension : Extension
+{
+ public static PermInfo PermUse = Permissions.Register(new(
+ "swarm_assistent_use",
+ "[Swarm Assistent] Use",
+ "Allows using the Swarm Assistent chat (Ollama proxy).",
+ PermissionDefault.USER,
+ Permissions.GroupUser));
+
+ public static HttpClient HttpClient;
+
+ public AssistentConfig Config;
+ public AssistentMemory Memory;
+
+ public override void OnPreInit()
+ {
+ ScriptFiles.Add("Assets/assistent.api.js");
+ ScriptFiles.Add("Assets/assistent.patch.js");
+ ScriptFiles.Add("Assets/assistent.persist.js");
+ ScriptFiles.Add("Assets/assistent.js");
+ StyleSheetFiles.Add("Assets/assistent.css");
+ ExtensionAuthor = "mrleo1nid";
+ Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
+ License = "MIT";
+ Version = "0.11.3";
+ Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
+ }
+
+ public override void OnInit()
+ {
+ HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
+ Config = new AssistentConfig(FilePath, DataRoot());
+ Memory = new AssistentMemory(DataRoot(), HttpClient, Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text");
+ API.RegisterAPICall(AssistentListModels, false, PermUse);
+ API.RegisterAPICall(AssistentGetPacks, false, PermUse);
+ API.RegisterAPICall(AssistentListPersonas, false, PermUse);
+ API.RegisterAPICall(AssistentGetConfig, false, PermUse);
+ API.RegisterAPICall(AssistentGetSettings, false, PermUse);
+ API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
+ API.RegisterAPICall(AssistentListInventory, false, PermUse);
+ API.RegisterAPICall(AssistentGetCard, false, PermUse);
+ API.RegisterAPICall(AssistentSaveCard, true, PermUse);
+ API.RegisterAPICall(AssistentEnqueueWanted, true, PermUse);
+ API.RegisterAPICall(AssistentGetCardMeta, false, PermUse);
+ API.RegisterAPICall(AssistentSearchCivitai, false, PermUse);
+ API.RegisterAPICall(AssistentGetTaste, false, PermUse);
+ API.RegisterAPICall(AssistentSaveTaste, true, PermUse);
+ API.RegisterAPICall(AssistentChat, true, PermUse);
+ API.RegisterAPICall(AssistentChatWS, true, PermUse);
+ API.RegisterAPICall(AssistentListChats, false, PermUse);
+ API.RegisterAPICall(AssistentGetChat, false, PermUse);
+ API.RegisterAPICall(AssistentSaveChat, true, PermUse);
+ API.RegisterAPICall(AssistentDeleteChat, true, PermUse);
+ API.RegisterAPICall(AssistentGetUiState, false, PermUse);
+ API.RegisterAPICall(AssistentSaveUiState, true, PermUse);
+ API.RegisterAPICall(AssistentParkLlm, true, PermUse);
+ API.RegisterAPICall(AssistentWarmLlm, true, PermUse);
+ API.RegisterAPICall(AssistentListMemory, false, PermUse);
+ API.RegisterAPICall(AssistentUpsertMemory, true, PermUse);
+ API.RegisterAPICall(AssistentForgetMemory, true, PermUse);
+ API.RegisterAPICall(AssistentSearchMemory, false, PermUse);
+ API.RegisterAPICall(AssistentGetMemory, false, PermUse);
+ API.RegisterAPICall(AssistentLookupTags, false, PermUse);
+ API.RegisterAPICall(AssistentListWanted, false, PermUse);
+ API.RegisterAPICall(AssistentSaveControls, true, PermUse);
+ API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
+ API.RegisterAPICall(AssistentClonePersona, true, PermUse);
+ API.RegisterAPICall(AssistentSavePersona, true, PermUse);
+ API.RegisterAPICall(AssistentDeletePersona, true, PermUse);
+ API.RegisterAPICall(AssistentExportPersona, false, PermUse);
+ API.RegisterAPICall(AssistentImportPersona, true, PermUse);
+ API.RegisterAPICall(AssistentSaveKnobs, true, PermUse);
+ API.RegisterAPICall(AssistentListUserPrefs, false, PermUse);
+ API.RegisterAPICall(AssistentUpsertUserPref, true, PermUse);
+ API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
+ API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
+ API.RegisterAPICall(AssistentClearMemory, true, PermUse);
+ Logs.Init("Swarm Assistent extension loaded (settings panel + user prefs + craft memory)");
+ }
+
+ int CfgInt(string key, int fallback)
+ {
+ try
+ {
+ return Config?.LoadAssistant(Config.DefaultPersonaId())[key]?.Value() ?? fallback;
+ }
+ catch
+ {
+ return fallback;
+ }
+ }
+
+ static string Clip(string text, int max)
+ {
+ if (string.IsNullOrEmpty(text) || text.Length <= max)
+ {
+ return text ?? "";
+ }
+ return text[..max] + "…";
+ }
+
+ static string CollapseWs(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return "";
+ }
+ return Regex.Replace(text.Trim(), @"\s+", " ");
+ }
+
+ public static string NormalizeBaseUrl(string raw)
+ {
+ string url = (raw ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ url = "http://127.0.0.1:11434";
+ }
+ return url.TrimEnd('/');
+ }
+
+ static string DataRoot()
+ {
+ if (Directory.Exists("/mnt/swarm_data"))
+ {
+ return "/mnt/swarm_data";
+ }
+ try
+ {
+ string models = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "Models"));
+ if (Directory.Exists(models))
+ {
+ return Path.GetDirectoryName(models) ?? Environment.CurrentDirectory;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ return Environment.CurrentDirectory;
+ }
+
+ public string ReadPackFile(string name)
+ {
+ return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name);
+ }
+
+ public async Task AssistentGetPacks(Session session, string persona = null)
+ {
+ await Task.CompletedTask;
+ string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
+ JObject packs = new();
+ JArray order = [];
+ foreach (var p in Config.ListPacks(pid))
+ {
+ string text = Config.LoadPackPrompt(pid, p.id);
+ if (text is not null)
+ {
+ packs[p.id] = text;
+ }
+ order.Add(p.id);
+ }
+ return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = order, ["persona"] = pid };
+ }
+
+ public async Task AssistentGetConfig(Session session, string persona = null)
+ {
+ await Task.CompletedTask;
+ string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
+ return Config.BuildMergedConfigPayload(pid);
+ }
+
+ public async Task AssistentGetSettings(Session session)
+ {
+ await Task.CompletedTask;
+ return new JObject { ["success"] = true, ["settings"] = Config.LoadSettings() };
+ }
+
+ public async Task AssistentSaveSettings(Session session, JObject settings)
+ {
+ await Task.CompletedTask;
+ if (settings is null)
+ {
+ return new JObject { ["error"] = "settings required" };
+ }
+ string prevEmbed = Config.LoadSettings()["embed_model"]?.ToString();
+ Config.SaveSettings(settings);
+ string nextEmbed = settings["embed_model"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(nextEmbed) && !string.Equals(prevEmbed, nextEmbed, StringComparison.OrdinalIgnoreCase))
+ {
+ try
+ {
+ await Memory.ReembedAllAsync(NormalizeBaseUrl(settings["base_url"]?.ToString()), nextEmbed);
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentSaveSettings reembed: {ex.Message}");
+ }
+ }
+ return new JObject { ["success"] = true, ["path"] = Path.Combine(Config.OverlayRoot, "settings.json") };
+ }
+
+ public async Task AssistentListPersonas(Session session)
+ {
+ await Task.CompletedTask;
+ var catalog = Config.ListPersonaCatalog();
+ JArray list = [];
+ foreach (var p in catalog)
+ {
+ list.Add(new JObject
+ {
+ ["id"] = p.id,
+ ["title"] = p.title,
+ ["accent"] = p.accent,
+ ["prompt"] = Config.RenderIdentityBlock(p.id, includeAllShelves: true),
+ ["source"] = p.source,
+ });
+ }
+ return new JObject
+ {
+ ["success"] = true,
+ ["default"] = Config.DefaultPersonaId(),
+ ["personas"] = list,
+ };
+ }
+
+ public async Task AssistentGetTaste(Session session)
+ {
+ await Task.CompletedTask;
+ try
+ {
+ return new JObject { ["success"] = true, ["taste"] = Memory.GetKvObject(AssistentMemory.KvTaste) };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"taste: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentSaveTaste(Session session, JObject taste)
+ {
+ await Task.CompletedTask;
+ if (taste is null)
+ {
+ return new JObject { ["error"] = "taste required" };
+ }
+ if (taste["updated"] == null)
+ {
+ taste["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ }
+ try
+ {
+ Memory.SetKvObject(AssistentMemory.KvTaste, taste);
+ return new JObject { ["success"] = true, ["path"] = "Assistent/memory/assistent.sqlite" };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"taste save: {ex.Message}" };
+ }
+ }
+}