From 56e089d8dae7e348c03cf5d64cec27886d709e4a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 22 Aug 2026 04:58:03 +0300 Subject: [PATCH] Ship Assistent 0.11.3: variants grid, EN Krea prep, and stream fence fix. Co-authored-by: Cursor --- Assets/assistent.css | 136 + Assets/assistent.js | 18062 ++++++++++++++------------- Assets/assistent.patch.js | 46 +- AssistentConfig.cs | 3 +- AssistentOllama.cs | 8 + AssistentPatch.cs | 87 +- Config/_base/assistant.json | 2 + Config/_base/core/core.md | 169 +- Config/_base/exact.json | 1 + Config/_base/packs/ordinary.md | 2 + Config/_base/packs/write_prompt.md | 3 +- Config/_base/rules.json | 2 +- Config/_base/skills/prompting.md | 28 +- Config/_base/ui.json | 4 +- Config/personas/leonid/craft.json | 3 +- Config/personas/leonid/rules.json | 2 +- README.md | 356 +- SwarmAssistentExtension.cs | 548 +- 18 files changed, 10244 insertions(+), 9218 deletions(-) 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
- - Напиши, что сгенерировать — или кинь референс и попроси правку.`; - - 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 = ``; - 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 = ` +
+ `; + 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 = ``; + 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}" }; + } + } +}