Move settings beside Chat/Cards; stop fence ramble, sticky scroll, horny echo filter, and force-warm after Generate. Co-authored-by: Cursor <cursoragent@cursor.com>
8537 lines
336 KiB
JavaScript
8537 lines
336 KiB
JavaScript
/**
|
||
* 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 = `
|
||
<div class="sa-welcome-title">Assistent · Krea 2</div>
|
||
<ul>
|
||
<li><strong>Generate</strong> слева — живой просмотр. В чат сам не уходит.</li>
|
||
<li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li>
|
||
<li>Галочка vision на окне — отправить кадр модели.</li>
|
||
<li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li>
|
||
<li>Кнопки патча только у последнего предложения.</li>
|
||
</ul>
|
||
Напиши, что сгенерировать — или кинь референс и попроси правку.`;
|
||
|
||
let HELP_TEXT = `Slash-команды (без LLM):
|
||
/help — этот список
|
||
/new — новый чат (текущий сохранится в Историю)
|
||
/history — открыть список чатов
|
||
/debug — сводка UI/Exact (без LLM)
|
||
/debug ask — то же + короткий ответ модели
|
||
/why — сразу /debug ask
|
||
/gen — Generate сейчас
|
||
/look generate|refN — прикрепить окно к vision
|
||
/init /mask /clear — Init / Mask / Clear Init
|
||
/interrupt — остановить генерацию
|
||
/aspect 16:9 — размер из таблицы 1K
|
||
/seed lock|random — зафиксировать или рандомизировать seed
|
||
/vary — новый seed, тот же промпт
|
||
/pack write|critique|compose|params|inpaint|describe|card
|
||
/civitai <query> — поиск LoRA (Confirm в чате)
|
||
/inventory — rescan моделей + обновить список LoRA
|
||
|
||
Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.
|
||
При старте всегда новый чат; смена чата в Истории восстанавливает параметры.`;
|
||
|
||
let SLASH_COMMANDS = [
|
||
{ cmd: '/help', hint: 'список команд' },
|
||
{ cmd: '/new', hint: 'новый чат' },
|
||
{ cmd: '/history', hint: 'история чатов' },
|
||
{ cmd: '/debug', hint: 'сводка · ask = с LLM' },
|
||
{ cmd: '/why', hint: 'debug + пояснение LLM' },
|
||
{ cmd: '/gen', hint: 'Generate сейчас' },
|
||
{ cmd: '/look ', hint: 'generate|refN' },
|
||
{ cmd: '/init', hint: 'как Init' },
|
||
{ cmd: '/mask', hint: 'как Mask' },
|
||
{ cmd: '/clear', hint: 'сброс Init/Mask' },
|
||
{ cmd: '/interrupt', hint: 'стоп' },
|
||
{ cmd: '/aspect ', hint: '16:9' },
|
||
{ cmd: '/seed ', hint: 'lock|random' },
|
||
{ cmd: '/vary', hint: 'новый seed' },
|
||
{ cmd: '/pack ', hint: 'write|critique|…' },
|
||
{ cmd: '/civitai ', hint: 'запрос LoRA' },
|
||
{ cmd: '/inventory', hint: 'rescan моделей' },
|
||
];
|
||
|
||
const state = {
|
||
history: [],
|
||
packsLoaded: false,
|
||
config: null,
|
||
exact: null,
|
||
sessionExact: {},
|
||
lastUserParamIntent: false,
|
||
lastUserControlIntent: false,
|
||
lastPatch: null,
|
||
pendingSilentGen: false,
|
||
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 <strong>Krea 2</strong> models only. Current: <code>${escapeHtml(
|
||
seen,
|
||
)}</code> — pick a checkpoint with architecture <code>krea-2</code>.`
|
||
: 'Swarm Assistent is for <strong>Krea 2</strong> models only. Select a Krea 2 checkpoint on Generate to enable the chat.';
|
||
}
|
||
}
|
||
}
|
||
if (layout) {
|
||
layout.classList.toggle('sa-disabled', !ok);
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, '&')
|
||
.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, '<code class="sa-prose-code">$1</code>');
|
||
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
t = t.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
|
||
return t;
|
||
}
|
||
|
||
/** Lightweight chat prose: ### Critique → «Критика», lists, bold — not a full markdown engine. */
|
||
function formatAssistantProseHtml(raw) {
|
||
let text = String(raw || '').replace(/\r\n/g, '\n');
|
||
text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n');
|
||
text = text.replace(/\n{3,}/g, '\n\n').trim();
|
||
if (!text) {
|
||
return '';
|
||
}
|
||
const lines = text.split('\n');
|
||
const parts = [];
|
||
let listItems = [];
|
||
const flushList = () => {
|
||
if (!listItems.length) {
|
||
return;
|
||
}
|
||
parts.push(
|
||
`<ul class="sa-prose-list">${listItems.map((li) => `<li>${formatProseInline(escapeHtml(li))}</li>`).join('')}</ul>`,
|
||
);
|
||
listItems = [];
|
||
};
|
||
for (const line of lines) {
|
||
const heading = line.match(/^#{1,3}\s+(.+?)\s*$/);
|
||
if (heading) {
|
||
flushList();
|
||
const title = localizeProseHeading(heading[1]);
|
||
if (!title) {
|
||
continue;
|
||
}
|
||
const level = Math.min((line.match(/^#+/) || ['###'])[0].length, 3);
|
||
parts.push(
|
||
`<div class="sa-prose-h sa-prose-h${level}" role="heading" aria-level="${level}">${escapeHtml(title)}</div>`,
|
||
);
|
||
continue;
|
||
}
|
||
const bullet = line.match(/^\s*[-*•]\s+(.+)$/);
|
||
if (bullet) {
|
||
listItems.push(bullet[1]);
|
||
continue;
|
||
}
|
||
flushList();
|
||
if (!line.trim()) {
|
||
parts.push('<div class="sa-prose-gap" aria-hidden="true"></div>');
|
||
continue;
|
||
}
|
||
parts.push(`<p class="sa-prose-p">${formatProseInline(escapeHtml(line))}</p>`);
|
||
}
|
||
flushList();
|
||
return parts.join('');
|
||
}
|
||
|
||
function setAssistantBody(div, text, { live = false } = {}) {
|
||
if (!div) {
|
||
return;
|
||
}
|
||
let body = div.querySelector('.sa-msg-body');
|
||
if (!body) {
|
||
body = document.createElement('div');
|
||
body.className = 'sa-msg-body';
|
||
div.appendChild(body);
|
||
}
|
||
const raw = text || '';
|
||
// While streaming, plain text — full HTML reformat every token reflows and shakes scroll.
|
||
if (live) {
|
||
body.classList.add('sa-prose', 'sa-prose-live');
|
||
body.classList.remove('sa-prose-rich');
|
||
body.textContent = raw;
|
||
return;
|
||
}
|
||
body.classList.add('sa-prose', 'sa-prose-rich');
|
||
body.classList.remove('sa-prose-live');
|
||
const html = formatAssistantProseHtml(raw);
|
||
if (html) {
|
||
body.innerHTML = html;
|
||
} else {
|
||
body.textContent = '';
|
||
}
|
||
}
|
||
|
||
function val(id) {
|
||
const el = document.getElementById(id);
|
||
return el ? el.value : '';
|
||
}
|
||
|
||
function setVal(id, value) {
|
||
const el = document.getElementById(id);
|
||
if (!el) {
|
||
return;
|
||
}
|
||
el.value = value;
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
function isEmptyParamField(raw, { treatZeroEmpty = false } = {}) {
|
||
if (raw == null) {
|
||
return true;
|
||
}
|
||
const s = String(raw).trim();
|
||
if (s === '') {
|
||
return true;
|
||
}
|
||
if (treatZeroEmpty && (s === '0' || Number(s) === 0)) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/** JS \\b/\\w are ASCII-only — use this for RU tokens. `alts` = regex alternatives without outer parens. */
|
||
function cyrTokenRe(alts) {
|
||
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
|
||
const end = '(?=$|[^0-9A-Za-z_А-Яа-яЁё])';
|
||
return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
|
||
}
|
||
|
||
/** Parse aspect from chat: 9:16, 9x16, 9×16, «9 на 16», «9 к 16». */
|
||
function parseAspectFromUserText(text) {
|
||
const t = String(text || '');
|
||
if (!t.trim()) {
|
||
return null;
|
||
}
|
||
const ratio = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*[:x×хX]\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/);
|
||
if (ratio) {
|
||
const key = normalizeAspect(`${ratio[1]}:${ratio[2]}`);
|
||
if (key) {
|
||
return key;
|
||
}
|
||
}
|
||
const na = t.match(/(?:^|[^0-9])(\d+(?:\.\d+)?)\s*(?:на|к|to)\s*(\d+(?:\.\d+)?)(?=$|[^0-9])/i);
|
||
if (na) {
|
||
const key = normalizeAspect(`${na[1]}:${na[2]}`);
|
||
if (key) {
|
||
return key;
|
||
}
|
||
}
|
||
const named = t.match(/\b(16:9|9:16|1:1|4:5|2:3|3:2|4:3|2\.35:1)\b/i);
|
||
if (named) {
|
||
return normalizeAspect(named[1]);
|
||
}
|
||
if (cyrTokenRe('портрет|вертикал[а-яё]*').test(t) || /\b(portrait|vertical)\b/i.test(t)) {
|
||
return normalizeAspect('9:16') || normalizeAspect('2:3');
|
||
}
|
||
if (cyrTokenRe('альбом|горизонтал[а-яё]*').test(t) || /\b(landscape|horizontal|widescreen)\b/i.test(t)) {
|
||
return normalizeAspect('16:9');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** «такую же, только 9 на 16» — keep prompt, change aspect, generate (no LLM needed). */
|
||
function isSameButAspectRequest(text) {
|
||
const t = String(text || '');
|
||
if (!parseAspectFromUserText(t)) {
|
||
return false;
|
||
}
|
||
return /такую\s+же|тот\s+же\s+промпт|same\s+(one|prompt|thing|again)|только\s+(поменя|смени|поставь)|поменяй\s+на|смени\s+на|only\s+change|just\s+change/i.test(t)
|
||
|| /поменяй\s+(размер|aspect|соотношен)/i.test(t)
|
||
|| /смени\s+(размер|aspect|соотношен)/i.test(t);
|
||
}
|
||
|
||
function userTextMentionsControls(text) {
|
||
const t = String(text || '');
|
||
if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) {
|
||
return true;
|
||
}
|
||
return cyrTokenRe('хорни|остынь|вкус|слайдер').test(t)
|
||
|| /\/\s*(остынь|ostyn|horny-game)/i.test(t);
|
||
}
|
||
|
||
function patchLooksLikeGeneration(patch) {
|
||
if (!patch || typeof patch !== 'object') {
|
||
return false;
|
||
}
|
||
if (patch.prompt != null || patch.loras || patch.aspect != null
|
||
|| patch.width != null || patch.height != null || patch.steps != null
|
||
|| patch.cfg != null || patch.seed != null) {
|
||
return true;
|
||
}
|
||
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
|
||
}
|
||
|
||
/** Drop Generate-patch echo of schema defaults that would wipe a user-tuned slider. */
|
||
function filterControlPatch(incoming, patch) {
|
||
const schema = state.config?.controls || {};
|
||
const out = {};
|
||
if (!incoming || typeof incoming !== 'object') {
|
||
return out;
|
||
}
|
||
const likeGen = patchLooksLikeGeneration(patch) && !state.lastUserControlIntent;
|
||
for (const [id, raw] of Object.entries(incoming)) {
|
||
if (!schema[id]) {
|
||
continue;
|
||
}
|
||
const n = Number(raw);
|
||
if (!Number.isFinite(n)) {
|
||
continue;
|
||
}
|
||
const def = Number(schema[id]?.default);
|
||
const cur = getControlValue(id, Number.isFinite(def) ? def : n);
|
||
if (Math.abs(n - cur) < 0.0005) {
|
||
continue;
|
||
}
|
||
if (likeGen && Number.isFinite(def) && Math.abs(n - def) < 0.0005 && Math.abs(cur - def) > 0.0005) {
|
||
continue;
|
||
}
|
||
out[id] = n;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function userTextMentionsParams(text) {
|
||
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);
|
||
}
|
||
|
||
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 isGenerateUnavailable() {
|
||
if (state.generating || state.busy) {
|
||
return true;
|
||
}
|
||
try {
|
||
if (typeof mainGenHandler !== 'undefined' && mainGenHandler) {
|
||
if (mainGenHandler.isGenerating === true || mainGenHandler.running === true) {
|
||
return true;
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
const interrupt = document.getElementById('interrupt_button')
|
||
|| document.getElementById('alt_interrupt_button');
|
||
if (interrupt && !interrupt.hidden && interrupt.offsetParent !== null) {
|
||
return true;
|
||
}
|
||
const genBtn = document.getElementById('generate_button') || document.getElementById('alt_generate_button');
|
||
if (genBtn && (genBtn.disabled || /interrupt/i.test(genBtn.textContent || ''))) {
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function syncGenerateBusy() {
|
||
const overlay = document.querySelector('.sa-slot-gen .sa-slot-busy');
|
||
if (overlay) {
|
||
overlay.hidden = !state.generating && state.busyPhase !== 'generating';
|
||
}
|
||
}
|
||
|
||
function syncPatchActionAvailability() {
|
||
const bar = document.querySelector('.sa-patch-actions.sa-patch-current');
|
||
if (!bar) {
|
||
return;
|
||
}
|
||
const locked = isGenerateUnavailable();
|
||
bar.querySelectorAll('.sa-btn-gen').forEach((btn) => {
|
||
btn.disabled = locked;
|
||
let spin = btn.querySelector('.sa-spinner');
|
||
if (locked) {
|
||
if (!spin) {
|
||
spin = document.createElement('span');
|
||
spin.className = 'sa-spinner sa-spinner-btn';
|
||
spin.setAttribute('aria-hidden', 'true');
|
||
btn.prepend(spin);
|
||
}
|
||
} else if (spin) {
|
||
spin.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
function retireStalePatchActions() {
|
||
document.querySelectorAll('.sa-patch-actions').forEach((el) => {
|
||
const note = document.createElement('div');
|
||
note.className = 'sa-patch-stale';
|
||
note.textContent = 'Superseded — use the latest proposal';
|
||
el.replaceWith(note);
|
||
});
|
||
}
|
||
|
||
function mountPatchBlock(host, patch, { silent = false } = {}) {
|
||
if (!host || !patch) {
|
||
return;
|
||
}
|
||
rememberLastPatch(patch);
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'sa-patch' + (silent ? ' sa-patch-auto' : '');
|
||
const details = document.createElement('details');
|
||
details.className = 'sa-patch-details';
|
||
const summary = document.createElement('summary');
|
||
const keys = Object.keys(patch).filter((k) => patch[k] != null && k !== 'notes' && k !== 'actions');
|
||
summary.textContent = silent
|
||
? `Патч применён · ${keys.slice(0, 6).join(', ') || 'generate'}`
|
||
: `JSON патч · ${keys.slice(0, 8).join(', ') || '…'}`;
|
||
const pre = document.createElement('pre');
|
||
pre.textContent = JSON.stringify(patch, null, 2);
|
||
details.appendChild(summary);
|
||
details.appendChild(pre);
|
||
wrap.appendChild(details);
|
||
mountPatchActions(wrap, patch, { silent });
|
||
host.appendChild(wrap);
|
||
}
|
||
|
||
function mountPatchActions(parent, patch, { silent = false } = {}) {
|
||
if (!parent || !patch) {
|
||
return;
|
||
}
|
||
rememberLastPatch(patch);
|
||
retireStalePatchActions();
|
||
const wrap = parent.classList.contains('sa-patch') ? parent : null;
|
||
const host = wrap || parent;
|
||
if (silent) {
|
||
const note = document.createElement('div');
|
||
note.className = 'sa-patch-actions sa-patch-silent sa-patch-current';
|
||
const willGen = Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')
|
||
|| !!state.pendingSilentGen;
|
||
note.textContent = willGen
|
||
? 'Применено автоматически · Generate…'
|
||
: 'Применено автоматически';
|
||
host.appendChild(note);
|
||
return;
|
||
}
|
||
const actions = document.createElement('div');
|
||
actions.className = 'sa-patch-actions sa-patch-current';
|
||
for (const [label, which] of [
|
||
['Применить всё', 'all'],
|
||
['Промпт', 'prompt'],
|
||
['LoRAs', 'loras'],
|
||
['Параметры', 'params'],
|
||
]) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'basic-button';
|
||
btn.textContent = label;
|
||
btn.addEventListener('click', () => applyPatch(patch, which));
|
||
actions.appendChild(btn);
|
||
}
|
||
const genBtn = document.createElement('button');
|
||
genBtn.type = 'button';
|
||
genBtn.className = 'basic-button sa-btn-gen';
|
||
genBtn.textContent = 'Применить + Generate';
|
||
genBtn.addEventListener('click', async () => {
|
||
if (isGenerateUnavailable()) {
|
||
return;
|
||
}
|
||
startBusyUi('silent_gen');
|
||
await applyPatch(patch, 'all');
|
||
await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
|
||
});
|
||
actions.appendChild(genBtn);
|
||
host.appendChild(actions);
|
||
syncPatchActionAvailability();
|
||
}
|
||
|
||
async function buildCurrentAndGenerate() {
|
||
if (state.busy || state.generating) {
|
||
setStatus('Занято — подожди или нажми Стоп');
|
||
return;
|
||
}
|
||
if (isGenerateUnavailable()) {
|
||
setStatus('Generate недоступен — дождись SwarmUI');
|
||
return;
|
||
}
|
||
const patch = state.lastPatch;
|
||
if (patch) {
|
||
startBusyUi('silent_gen');
|
||
setStatus('Собираю патч → Generate…');
|
||
await applyPatch(patch, 'all');
|
||
syncLiveParamsBar();
|
||
await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
|
||
return;
|
||
}
|
||
startBusyUi('generating');
|
||
setStatus('Generate с текущим промптом…');
|
||
await runGenerateFromPatch({ actions: ['generate'] }, { force: true });
|
||
}
|
||
|
||
function renderBoard() {
|
||
const board = $('sa_board');
|
||
if (!board) {
|
||
return;
|
||
}
|
||
ensureBoard();
|
||
const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
|
||
const refs = refSlots();
|
||
board.classList.toggle('sa-board-many', tab === 'refs' && (refs.some((s) => s.src) || refs.length > 1));
|
||
board.classList.toggle('sa-board-gen-only', tab === 'generate');
|
||
board.innerHTML = '';
|
||
const toShow = tab === 'generate'
|
||
? state.slots.filter((s) => s.type === 'generate')
|
||
: state.slots.filter((s) => s.type !== 'generate');
|
||
for (const slot of toShow) {
|
||
board.appendChild(buildSlotEl(slot));
|
||
}
|
||
if (tab === 'refs' && refs.length < MAX_REF_SLOTS) {
|
||
const add = document.createElement('div');
|
||
add.className = 'sa-add-cell';
|
||
add.textContent = '+ Ref';
|
||
add.title = 'Добавить окно референса';
|
||
add.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
addRefSlot({ select: true });
|
||
});
|
||
add.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
});
|
||
add.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const created = addRefSlot({ select: true });
|
||
if (created) {
|
||
state.selectedSlotId = created.id;
|
||
await handleDropDataTransfer(e.dataTransfer, created.id);
|
||
}
|
||
});
|
||
board.appendChild(add);
|
||
}
|
||
syncBoardChrome();
|
||
syncGenerateBusy();
|
||
syncLastImageAlias();
|
||
}
|
||
|
||
function syncBoardChrome() {
|
||
const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
|
||
$('sa_board_tab_gen')?.classList.toggle('sa-board-tab-active', tab === 'generate');
|
||
$('sa_board_tab_refs')?.classList.toggle('sa-board-tab-active', tab === 'refs');
|
||
$('sa_board_tab_gen')?.setAttribute('aria-selected', tab === 'generate' ? 'true' : 'false');
|
||
$('sa_board_tab_refs')?.setAttribute('aria-selected', tab === 'refs' ? 'true' : 'false');
|
||
const addBtn = $('sa_btn_add_ref');
|
||
if (addBtn) {
|
||
addBtn.hidden = tab !== 'refs';
|
||
}
|
||
const maskBtn = $('sa_btn_as_mask');
|
||
const clearSlotBtn = $('sa_btn_clear_image');
|
||
if (maskBtn) {
|
||
maskBtn.hidden = tab !== 'refs';
|
||
}
|
||
if (clearSlotBtn) {
|
||
clearSlotBtn.hidden = tab !== 'refs';
|
||
}
|
||
const badge = $('sa_refs_badge');
|
||
if (badge) {
|
||
const refs = refSlots();
|
||
const withImg = refs.filter((s) => s.src).length;
|
||
const withVision = refs.filter((s) => s.src && s.attach).length;
|
||
if (withImg || withVision) {
|
||
badge.hidden = false;
|
||
badge.textContent = withVision ? `${withImg} · vision ${withVision}` : String(withImg);
|
||
} else {
|
||
badge.hidden = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function setBoardTab(tab, { persist = true } = {}) {
|
||
state.boardTab = tab === 'refs' ? 'refs' : 'generate';
|
||
if (persist) {
|
||
try {
|
||
localStorage.setItem(LS_BOARD_TAB, state.boardTab);
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
renderBoard();
|
||
}
|
||
|
||
function buildSlotEl(slot) {
|
||
const el = document.createElement('div');
|
||
el.className = `sa-slot${slot.type === 'generate' ? ' sa-slot-gen' : ''}`;
|
||
el.dataset.id = slot.id;
|
||
if (slot.src) {
|
||
el.classList.add('sa-has-image');
|
||
}
|
||
if (slot.id === state.selectedSlotId) {
|
||
el.classList.add('sa-selected');
|
||
}
|
||
const bar = document.createElement('div');
|
||
bar.className = 'sa-slot-bar';
|
||
const chip = document.createElement('span');
|
||
chip.className = `sa-slot-chip${slot.type === 'generate' ? ' sa-live' : ''}`;
|
||
chip.textContent = slot.type === 'generate' ? 'Generate' : slot.label;
|
||
bar.appendChild(chip);
|
||
const attachLab = document.createElement('label');
|
||
attachLab.className = 'sa-slot-attach';
|
||
attachLab.title = 'Attach this window to the next chat (vision)';
|
||
const cb = document.createElement('input');
|
||
cb.type = 'checkbox';
|
||
cb.checked = !!slot.attach;
|
||
cb.addEventListener('click', (e) => e.stopPropagation());
|
||
cb.addEventListener('change', (e) => {
|
||
e.stopPropagation();
|
||
slot.attach = cb.checked;
|
||
syncLastImageAlias();
|
||
});
|
||
attachLab.appendChild(cb);
|
||
attachLab.appendChild(document.createTextNode(' vision'));
|
||
bar.appendChild(attachLab);
|
||
el.appendChild(bar);
|
||
|
||
if (slot.src) {
|
||
const img = document.createElement('img');
|
||
img.alt = slot.label;
|
||
img.src = slot.src;
|
||
el.appendChild(img);
|
||
} else {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'sa-image-empty';
|
||
empty.innerHTML = slot.type === 'generate'
|
||
? '<div class="sa-empty-title">Generate</div><div class="sa-empty-hint">Живой просмотр текущей генерации</div>'
|
||
: '<div class="sa-empty-title">Reference</div><div class="sa-empty-hint">Drop · paste · Снимок gen</div>';
|
||
el.appendChild(empty);
|
||
}
|
||
|
||
const busy = document.createElement('div');
|
||
busy.className = 'sa-slot-busy';
|
||
busy.hidden = !(slot.type === 'generate' && (state.generating || state.busyPhase === 'generating'));
|
||
busy.innerHTML = '<span class="sa-spinner" aria-hidden="true"></span>';
|
||
el.appendChild(busy);
|
||
|
||
el.addEventListener('click', () => {
|
||
state.selectedSlotId = slot.id;
|
||
renderBoard();
|
||
});
|
||
el.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
el.classList.add('sa-dragover');
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
});
|
||
el.addEventListener('dragleave', () => el.classList.remove('sa-dragover'));
|
||
el.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
el.classList.remove('sa-dragover');
|
||
const targetId = slot.type === 'generate' ? null : slot.id;
|
||
if (slot.type === 'generate') {
|
||
const created = addRefSlot({ select: true });
|
||
await handleDropDataTransfer(e.dataTransfer, created?.id);
|
||
setBoardTab('refs');
|
||
} else {
|
||
await handleDropDataTransfer(e.dataTransfer, targetId);
|
||
}
|
||
});
|
||
return el;
|
||
}
|
||
|
||
function syncGenerateSlot() {
|
||
const slot = generateSlot();
|
||
if (!slot) {
|
||
return;
|
||
}
|
||
// Drop checkpoint/LoRA card previews that slipped into the Generate pane.
|
||
if (scrubPreviewFromGenerateSlot()) {
|
||
renderBoard();
|
||
}
|
||
const src = findCurrentGenerateSrc();
|
||
if (src && src !== slot.src) {
|
||
slot.src = src;
|
||
const img = document.querySelector('.sa-slot-gen img');
|
||
const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
|
||
const frame = document.querySelector('.sa-slot-gen');
|
||
if (img) {
|
||
img.src = src;
|
||
} else if (frame) {
|
||
renderBoard();
|
||
return;
|
||
}
|
||
if (empty) {
|
||
empty.hidden = true;
|
||
}
|
||
frame?.classList.add('sa-has-image');
|
||
} else if (!src && !slot.src) {
|
||
const empty = document.querySelector('.sa-slot-gen .sa-image-empty');
|
||
const frame = document.querySelector('.sa-slot-gen');
|
||
const img = document.querySelector('.sa-slot-gen img');
|
||
if (img) {
|
||
img.remove();
|
||
}
|
||
if (empty) {
|
||
empty.hidden = false;
|
||
}
|
||
frame?.classList.remove('sa-has-image', 'sa-attached');
|
||
}
|
||
syncGenerateBusy();
|
||
syncPatchActionAvailability();
|
||
}
|
||
|
||
function maybeWelcome() {
|
||
if (localStorage.getItem(LS_WELCOMED) === '1') {
|
||
return;
|
||
}
|
||
if (!$('sa_messages')) {
|
||
return;
|
||
}
|
||
localStorage.setItem(LS_WELCOMED, '1');
|
||
const box = $('sa_messages');
|
||
hideChatEmpty();
|
||
const div = document.createElement('div');
|
||
div.className = 'sa-msg assistant sa-welcome';
|
||
div.innerHTML = WELCOME_HTML;
|
||
box.appendChild(div);
|
||
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
|
||
|| '<div class="sa-chat-empty-title">Новый чат</div><div class="sa-chat-empty-hint">Параметры Generate остаются как сейчас.<br><strong>+</strong> — ещё один чат · <strong>История</strong> — вернуться к прошлому (с его параметрами).</div>';
|
||
box.appendChild(empty);
|
||
}
|
||
|
||
function renderHistoryIntoUi(messages) {
|
||
const box = $('sa_messages');
|
||
if (!box) {
|
||
return;
|
||
}
|
||
box.innerHTML = '';
|
||
const list = slimHistoryMessages(messages);
|
||
if (!list.length) {
|
||
resetMessagesUi();
|
||
return;
|
||
}
|
||
for (const m of list) {
|
||
if (m.role === 'user') {
|
||
appendMessage('user', m.content, null, null, { historical: true });
|
||
} else {
|
||
appendMessage('assistant', m.content, null, null, {
|
||
persona: m.persona ? { id: m.persona, title: m.persona } : null,
|
||
pack: m.pack,
|
||
historical: true,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateSessionLabel() {
|
||
const el = $('sa_session_label');
|
||
if (!el) {
|
||
return;
|
||
}
|
||
const chat = findChat(state.activeChatId);
|
||
el.textContent = chat?.title || 'Новый чат';
|
||
el.title = (chat?.title || 'Новый чат') + ' — клик: История';
|
||
}
|
||
|
||
function savedChatsCount() {
|
||
return (state.chats || []).filter((c) => (c.messages || []).length > 0).length;
|
||
}
|
||
|
||
function syncHistoryBadge() {
|
||
const btn = $('sa_btn_chats');
|
||
if (!btn) {
|
||
return;
|
||
}
|
||
const n = savedChatsCount();
|
||
btn.textContent = n > 0 ? `История (${n})` : 'История';
|
||
btn.title = n > 0
|
||
? `Сохранённых чатов: ${n}. Переключение восстанавливает параметры.`
|
||
: 'История чатов (пока пусто)';
|
||
}
|
||
|
||
function formatChatWhen(ts) {
|
||
if (!ts) {
|
||
return '';
|
||
}
|
||
try {
|
||
return new Date(ts).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||
} catch (e) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function chatMatchesQuery(chat, q) {
|
||
if (!q) {
|
||
return true;
|
||
}
|
||
const title = String(chat?.title || '').toLowerCase();
|
||
if (title.includes(q)) {
|
||
return true;
|
||
}
|
||
const msgs = chat?.messages || [];
|
||
for (const m of msgs) {
|
||
if (String(m?.content || '').toLowerCase().includes(q)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function renderChatsList() {
|
||
const root = $('sa_chats_list');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
root.innerHTML = '';
|
||
syncHistoryBadge();
|
||
const q = (state.chatsQuery || '').trim().toLowerCase();
|
||
let chats = (state.chats || [])
|
||
.slice()
|
||
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
||
.filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
|
||
if (q) {
|
||
const local = chats.filter((c) => chatMatchesQuery(c, q));
|
||
const seen = new Set(local.map((c) => c.id));
|
||
const extra = (state.chatsSearchHits || []).filter((h) => h && h.id && !seen.has(h.id));
|
||
chats = local.concat(extra);
|
||
}
|
||
if (!chats.length) {
|
||
root.innerHTML = q
|
||
? '<div class="sa-chats-empty">Ничего не нашлось.</div>'
|
||
: '<div class="sa-chats-empty">Пока пусто. Напиши что-нибудь в чат — он появится здесь. Переключение вернёт и сообщения, и параметры Generate.</div>';
|
||
return;
|
||
}
|
||
for (const c of chats) {
|
||
const row = document.createElement('div');
|
||
row.className = 'sa-chat-row' + (c.id === state.activeChatId ? ' sa-chat-row-active' : '');
|
||
row.dataset.id = c.id;
|
||
const n = (c.messages || []).length || Number(c.messages_count) || 0;
|
||
const bits = [];
|
||
if (c.params?.width && c.params?.height) {
|
||
bits.push(`${c.params.width}×${c.params.height}`);
|
||
}
|
||
if (c.params?.steps != null) {
|
||
bits.push(`steps ${c.params.steps}`);
|
||
}
|
||
if (c.params?.cfg != null) {
|
||
bits.push(`cfg ${c.params.cfg}`);
|
||
}
|
||
if (Array.isArray(c.params?.loras) && c.params.loras.length) {
|
||
bits.push(`LoRA ${c.params.loras.length}`);
|
||
}
|
||
const noParams = !c.params ? ' · без снимка params' : '';
|
||
row.innerHTML = `<button type="button" class="sa-chat-row-main" data-open="1"><span class="sa-chat-row-title">${escapeHtml(c.title || 'Чат')}</span><span class="sa-chat-row-meta">${escapeHtml([formatChatWhen(c.updatedAt), `${n} сообщ.`, bits.join(' · ')].filter(Boolean).join(' · ') + noParams)}</span></button><button type="button" class="sa-chat-row-del" data-del="1" title="Удалить чат">×</button>`;
|
||
root.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function setChatsPanelOpen(open) {
|
||
state.chatsPanelOpen = !!open;
|
||
const panel = $('sa_chats_panel');
|
||
const btn = $('sa_btn_chats');
|
||
if (panel) {
|
||
panel.hidden = !state.chatsPanelOpen;
|
||
}
|
||
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
|
||
if (state.chatsPanelOpen) {
|
||
saveActiveChatToStore();
|
||
const search = $('sa_chats_search');
|
||
if (search) {
|
||
search.value = state.chatsQuery || '';
|
||
search.focus();
|
||
}
|
||
renderChatsList();
|
||
}
|
||
}
|
||
|
||
async function startNewChat({ saveCurrent = true, force = false } = {}) {
|
||
if (!force && (state.busy || state.generating)) {
|
||
setStatus('Занято — дождись конца ответа или Стоп');
|
||
return;
|
||
}
|
||
if (force) {
|
||
// Drop in-flight reply so it cannot land in the new chat.
|
||
abortInFlightWork({ status: '' });
|
||
}
|
||
setChatsPanelOpen(false);
|
||
if (saveCurrent) {
|
||
saveActiveChatToStore({ dropEmpty: true });
|
||
}
|
||
state.sessionExact = {};
|
||
state.lastUserParamIntent = false;
|
||
state.pendingSilentGen = false;
|
||
state.lastPatch = null;
|
||
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('<div class="sa-chat-empty-title">Чат очищен</div><div class="sa-chat-empty-hint">Сообщения сброшены. Параметры Generate на месте. <strong>+</strong> — новый чат в Историю, <strong>История</strong> — прошлые диалоги.</div>');
|
||
setStatus('Чат очищен');
|
||
updateSessionLabel();
|
||
syncHistoryBadge();
|
||
}
|
||
|
||
function hideSlashMenu() {
|
||
const menu = $('sa_slash_menu');
|
||
if (menu) {
|
||
menu.hidden = true;
|
||
menu.innerHTML = '';
|
||
}
|
||
state.slashIndex = 0;
|
||
}
|
||
|
||
function slashMatches(text) {
|
||
const t = String(text || '');
|
||
if (!t.startsWith('/')) {
|
||
return [];
|
||
}
|
||
const q = t.toLowerCase();
|
||
return SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q) || q === '/' || c.cmd.toLowerCase().includes(q.slice(1)));
|
||
}
|
||
|
||
function renderSlashMenu(items) {
|
||
const menu = $('sa_slash_menu');
|
||
if (!menu) {
|
||
return;
|
||
}
|
||
if (!items.length) {
|
||
hideSlashMenu();
|
||
return;
|
||
}
|
||
menu.hidden = false;
|
||
menu.innerHTML = '';
|
||
state.slashIndex = Math.max(0, Math.min(state.slashIndex, items.length - 1));
|
||
items.forEach((item, i) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'sa-slash-item' + (i === state.slashIndex ? ' sa-slash-active' : '');
|
||
btn.setAttribute('role', 'option');
|
||
btn.innerHTML = `<code>${escapeHtml(item.cmd.trim())}</code> — ${escapeHtml(item.hint)}`;
|
||
btn.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
applySlashPick(item);
|
||
});
|
||
menu.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function applySlashPick(item) {
|
||
const input = $('sa_input');
|
||
if (!input || !item) {
|
||
return;
|
||
}
|
||
input.value = item.cmd;
|
||
hideSlashMenu();
|
||
input.focus();
|
||
const pos = input.value.length;
|
||
input.setSelectionRange(pos, pos);
|
||
}
|
||
|
||
function updateSlashMenuFromInput() {
|
||
const text = $('sa_input')?.value || '';
|
||
if (!text.startsWith('/') || text.includes('\n') || /\s/.test(text.trim().slice(1)) && !text.endsWith(' ')) {
|
||
// show while typing command token only
|
||
const token = text.split(/\s/)[0] || '';
|
||
if (!token.startsWith('/') || (text.includes(' ') && !SLASH_COMMANDS.some((c) => c.cmd.startsWith(token)))) {
|
||
if (!(token.startsWith('/') && !text.includes(' '))) {
|
||
hideSlashMenu();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
const token = (text.split(/\s/)[0] || '');
|
||
if (!token.startsWith('/') || text.indexOf(' ') > 0) {
|
||
hideSlashMenu();
|
||
return;
|
||
}
|
||
renderSlashMenu(slashMatches(token));
|
||
}
|
||
|
||
function onPersonaChanged() {
|
||
const id = $('sa_persona')?.value || 'neutral';
|
||
state.sessionExact = {};
|
||
state.lastUserParamIntent = false;
|
||
saveSettings();
|
||
loadConfig(id, (data) => {
|
||
const title = data?.personas?.find((p) => p.id === id)?.title
|
||
|| (state.personas || []).find((p) => p.id === id)?.title
|
||
|| id;
|
||
if (data?.personas) {
|
||
state.personas = data.personas;
|
||
}
|
||
appendSystemNote(`Тон → ${title}`);
|
||
state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
|
||
if (data?.assistant?.default_pack && $('sa_pack') && !state.packUserTouched) {
|
||
const packId = data.assistant.default_pack;
|
||
if ([...($('sa_pack').options || [])].some((o) => o.value === packId)) {
|
||
$('sa_pack').value = packId;
|
||
}
|
||
}
|
||
fillEmptyParamsFromExact();
|
||
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
|
||
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
|
||
if (state.view === 'settings') {
|
||
if (state.settingsTab === 'user') {
|
||
refreshUserPrefs();
|
||
}
|
||
if (state.settingsTab === 'craft') {
|
||
renderMemoryList();
|
||
}
|
||
if (state.settingsTab === 'more') {
|
||
fillKnobsFromConfig(data);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function countPromptImages() {
|
||
try {
|
||
const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
|
||
if (!box) {
|
||
return 0;
|
||
}
|
||
const text = box.value || '';
|
||
const matches = text.match(/<image(?:\s|\/|>)/gi) || text.match(/data:image\//gi);
|
||
return matches ? matches.length : 0;
|
||
} catch (e) {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function triggerChangeForEl(el) {
|
||
if (!el) {
|
||
return;
|
||
}
|
||
if (typeof triggerChangeFor === 'function') {
|
||
triggerChangeFor(el);
|
||
return;
|
||
}
|
||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||
}
|
||
|
||
function openInitImageGroup() {
|
||
try {
|
||
const initEl = document.getElementById('input_initimage');
|
||
if (initEl && typeof toggleGroupOpen === 'function') {
|
||
toggleGroupOpen(initEl, true);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
const toggler = document.getElementById('input_group_content_initimage_toggle');
|
||
if (toggler) {
|
||
toggler.checked = true;
|
||
triggerChangeForEl(toggler);
|
||
}
|
||
}
|
||
|
||
function hasFileParam(id) {
|
||
const el = document.getElementById(id);
|
||
return !!(el && el.files && el.files.length > 0);
|
||
}
|
||
|
||
function clearFileParam(id) {
|
||
const el = document.getElementById(id);
|
||
if (!el) {
|
||
return false;
|
||
}
|
||
try {
|
||
el.value = '';
|
||
if (el.files && typeof DataTransfer !== 'undefined') {
|
||
el.files = new DataTransfer().files;
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
triggerChangeForEl(el);
|
||
return true;
|
||
}
|
||
|
||
function guessImageMime(src) {
|
||
const s = String(src || '');
|
||
if (s.startsWith('data:image/')) {
|
||
const m = s.match(/^data:(image\/[a-z0-9.+-]+)/i);
|
||
return (m && m[1]) || 'image/png';
|
||
}
|
||
const path = s.split('?')[0];
|
||
const ext = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
|
||
if (ext === 'jpg' || ext === 'jpeg') {
|
||
return 'image/jpeg';
|
||
}
|
||
if (ext === 'webp') {
|
||
return 'image/webp';
|
||
}
|
||
if (ext === 'gif') {
|
||
return 'image/gif';
|
||
}
|
||
return 'image/png';
|
||
}
|
||
|
||
async function srcToBlob(src) {
|
||
if (!src) {
|
||
return null;
|
||
}
|
||
const cleaned = String(src).trim().split(/\s+/)[0];
|
||
if (cleaned.startsWith('data:') || cleaned.startsWith('/') || cleaned.startsWith('View/') || cleaned.startsWith('http')) {
|
||
try {
|
||
const resp = await fetch(cleaned);
|
||
return await resp.blob();
|
||
} catch (e) {
|
||
console.warn('Assistent: fetch blob failed', e);
|
||
}
|
||
}
|
||
return await new Promise((resolve) => {
|
||
const tmpImg = new Image();
|
||
tmpImg.crossOrigin = 'Anonymous';
|
||
tmpImg.onload = () => {
|
||
try {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = tmpImg.naturalWidth;
|
||
canvas.height = tmpImg.naturalHeight;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx.drawImage(tmpImg, 0, 0);
|
||
canvas.toBlob((blob) => resolve(blob), 'image/png');
|
||
} catch (e) {
|
||
resolve(null);
|
||
}
|
||
};
|
||
tmpImg.onerror = () => resolve(null);
|
||
tmpImg.src = cleaned;
|
||
});
|
||
}
|
||
|
||
async function setFileParamFromSrc(paramId, src, { filename = 'assistent.png' } = {}) {
|
||
const el = document.getElementById(paramId);
|
||
if (!el) {
|
||
setStatus(`Missing ${paramId} on Generate tab`);
|
||
return false;
|
||
}
|
||
const blob = await srcToBlob(src);
|
||
if (!blob) {
|
||
setStatus('Could not load image for init/mask');
|
||
return false;
|
||
}
|
||
const mime = blob.type || guessImageMime(src);
|
||
const file = new File([blob], filename, { type: mime });
|
||
const container = new DataTransfer();
|
||
container.items.add(file);
|
||
el.files = container.files;
|
||
triggerChangeForEl(el);
|
||
openInitImageGroup();
|
||
return true;
|
||
}
|
||
|
||
async function setInitFromSrc(src) {
|
||
const ok = await setFileParamFromSrc('input_initimage', src, { filename: 'assistent_init.png' });
|
||
if (ok) {
|
||
setStatus('Init Image set');
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
async function setMaskFromSrc(src) {
|
||
const ok = await setFileParamFromSrc('input_maskimage', src, { filename: 'assistent_mask.png' });
|
||
if (ok) {
|
||
setStatus('Mask Image set (white = edit)');
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
function clearInitAndMask() {
|
||
clearFileParam('input_initimage');
|
||
clearFileParam('input_maskimage');
|
||
const toggler = document.getElementById('input_group_content_initimage_toggle');
|
||
if (toggler) {
|
||
toggler.checked = false;
|
||
triggerChangeForEl(toggler);
|
||
}
|
||
setStatus('Init Image + Mask cleared');
|
||
}
|
||
|
||
function readInitContext() {
|
||
const creativityRaw = val('input_initimagecreativity');
|
||
const creativity = creativityRaw === '' ? null : parseFloat(creativityRaw);
|
||
return {
|
||
has_init_image: hasFileParam('input_initimage'),
|
||
has_mask_image: hasFileParam('input_maskimage'),
|
||
init_creativity: Number.isFinite(creativity) ? creativity : null,
|
||
mask_blur: parseFloat(val('input_maskblur') || '') || null,
|
||
mask_grow: parseInt(val('input_maskgrow') || val('input_maskshrinkgrow') || '', 10) || null,
|
||
init_group_on: !!(document.getElementById('input_group_content_initimage_toggle')?.checked),
|
||
};
|
||
}
|
||
|
||
function slimPromptForContext(raw) {
|
||
let s = String(raw || '');
|
||
s = s.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '[image omitted]');
|
||
s = s.replace(/<image\b[^>]*>[\s\S]*?<\/image>/gi, '[image omitted]');
|
||
s = s.replace(/<img\b[^>]*>/gi, '[image omitted]');
|
||
if (s.length > CONTEXT_PROMPT_MAX) {
|
||
s = s.slice(0, CONTEXT_PROMPT_MAX) + '…';
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function collectLiveContext() {
|
||
const inv = state.inventory || {};
|
||
const initCtx = readInitContext();
|
||
const ctx = {
|
||
architecture_ok: isKreaSelected(),
|
||
checkpoint: null,
|
||
prompt: slimPromptForContext(val('alt_prompt_textbox') || val('input_prompt') || ''),
|
||
negative: slimPromptForContext(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || ''),
|
||
width: parseInt(val('input_width') || '0', 10) || null,
|
||
height: parseInt(val('input_height') || '0', 10) || null,
|
||
steps: parseInt(val('input_steps') || '0', 10) || null,
|
||
cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null,
|
||
sigma_shift: parseFloat(val('input_sigmashift') || '') || null,
|
||
seed: val('input_seed') || null,
|
||
sampler: val('input_sampler') || val('input_samplerate') || null,
|
||
scheduler: val('input_scheduler') || null,
|
||
batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
|
||
prompt_image_count: countPromptImages(),
|
||
selected_loras: [],
|
||
available_loras: [],
|
||
available_checkpoints: slimInventoryCheckpoints(inv.checkpoints || [], 8),
|
||
wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 20),
|
||
inventory_at: inv.inventory_at || null,
|
||
has_vision_image: visionReadySlots().length > 0,
|
||
image_slots: slotCatalog(),
|
||
attached_slot_ids: attachableSlots().map((s) => s.id),
|
||
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(/<image\b[^>]*>[\s\S]*?<\/image>/gi, '')
|
||
.replace(/<image\b[^>]*\/?>/gi, '')
|
||
.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim();
|
||
if (next === before.trim()) {
|
||
return false;
|
||
}
|
||
box.value = next;
|
||
box.dispatchEvent(new Event('input', { bubbles: true }));
|
||
box.dispatchEvent(new Event('change', { bubbles: true }));
|
||
return true;
|
||
}
|
||
|
||
function clearPatchBlocksOnly() {
|
||
document.querySelectorAll('#sa_messages .sa-patch, #sa_messages .sa-patch-stale, #sa_messages .sa-civitai-list').forEach((el) => el.remove());
|
||
state.lastPatch = null;
|
||
syncBuildGenButton();
|
||
setStatus('Патчи убраны из чата');
|
||
}
|
||
|
||
function toggleMoreMenu(menuId, btnId) {
|
||
const menu = $(menuId);
|
||
const btn = $(btnId);
|
||
if (!menu) {
|
||
return;
|
||
}
|
||
const open = menu.hidden;
|
||
document.querySelectorAll('.sa-more-menu').forEach((m) => {
|
||
m.hidden = true;
|
||
});
|
||
document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
|
||
if (open) {
|
||
menu.hidden = false;
|
||
btn?.setAttribute('aria-expanded', 'true');
|
||
}
|
||
}
|
||
|
||
function closeAllMoreMenus() {
|
||
document.querySelectorAll('.sa-more-menu').forEach((m) => {
|
||
m.hidden = true;
|
||
});
|
||
document.querySelectorAll('#sa_btn_board_more, #sa_btn_clear_more').forEach((b) => b.setAttribute('aria-expanded', 'false'));
|
||
}
|
||
|
||
function setPackValue(packName, { flash, user } = {}) {
|
||
const pack = $('sa_pack');
|
||
if (!pack || !packName) {
|
||
return false;
|
||
}
|
||
const resolved = PACK_ALIASES[String(packName).trim()] || String(packName).trim();
|
||
if (![...pack.options].some((o) => o.value === resolved)) {
|
||
return false;
|
||
}
|
||
if (pack.value !== resolved) {
|
||
pack.value = resolved;
|
||
saveSettings();
|
||
}
|
||
if (user) {
|
||
state.packUserTouched = true;
|
||
}
|
||
if (flash) {
|
||
pack.classList.add('sa-pack-flash');
|
||
setTimeout(() => pack.classList.remove('sa-pack-flash'), 900);
|
||
}
|
||
syncModeBadge();
|
||
return true;
|
||
}
|
||
|
||
function autoSelectPack(text) {
|
||
if (state.packUserTouched) {
|
||
return null;
|
||
}
|
||
// Комбайн «Обычный» сам выбирает поведение — не переключаем pack.
|
||
const cur = $('sa_pack')?.value || defaultPackId();
|
||
if (cur === 'ordinary') {
|
||
return null;
|
||
}
|
||
const t = String(text || '').toLowerCase();
|
||
if (!t.trim()) {
|
||
return null;
|
||
}
|
||
// Param / aspect asks must leave a stuck critique_image pack from auto-critique.
|
||
if (userTextMentionsParams(t) || parseAspectFromUserText(t)) {
|
||
return cur === 'critique_image' || cur === 'describe_ref' ? 'ordinary' : 'form_params';
|
||
}
|
||
if (cyrTokenRe('поправь|исправь|перепиши|улучши').test(t)
|
||
|| /\b(fix\s+it|make\s+it\s+better|rewrite)\b/i.test(t)) {
|
||
return 'write_prompt';
|
||
}
|
||
if (cyrTokenRe('опиши\\s+(реф|изображ[а-яё]*|этот|эту|картинк[а-яё]*|референс)').test(t)
|
||
|| /\b(prompt\s+from\s+image|describe\s+(this|the|ref|image)|reverse\s*prompt)\b/i.test(t)
|
||
|| /опиши\s+(этот|эту|картинк|референс)/i.test(t)) {
|
||
return 'describe_ref';
|
||
}
|
||
// Bare «посмотри/смотри» is casual chat — only critique when aimed at a result/frame.
|
||
if (/\b(critique|criticize)\b/i.test(t)
|
||
|| cyrTokenRe('критик[а-яё]*|что\\s+не\\s+так|разбери').test(t)
|
||
|| /(?:^|[^а-яёa-z0-9_])(посмотри|смотри)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген)/i.test(t)) {
|
||
return 'critique_image';
|
||
}
|
||
if (/\b(inpaint|mask|img2img)\b/i.test(t)
|
||
|| cyrTokenRe('замажь|закрась|руки|лицо|маск[а-яё]*').test(t)
|
||
|| /init\s*image/i.test(t)) {
|
||
return 'inpaint_edit';
|
||
}
|
||
// Any non-critique follow-up after auto-critique should leave critique mode.
|
||
if (cur === 'critique_image') {
|
||
return 'ordinary';
|
||
}
|
||
if (/\b(moodboard|compose|scene)\b/i.test(t)
|
||
|| cyrTokenRe('сцен[а-яё]*|атмосфер[а-яё]*|мизансцен[а-яё]*').test(t)) {
|
||
return 'compose_scene';
|
||
}
|
||
return 'write_prompt';
|
||
}
|
||
|
||
function restoreDefaultPackAfterHop() {
|
||
if (state.packUserTouched) {
|
||
return;
|
||
}
|
||
const cur = $('sa_pack')?.value || '';
|
||
if (cur === 'critique_image' || cur === 'describe_ref') {
|
||
setPackValue(defaultPackId(), { flash: true });
|
||
}
|
||
}
|
||
|
||
function patchHasGenTrigger(patch) {
|
||
if (!patch) {
|
||
return false;
|
||
}
|
||
if (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate')) {
|
||
return true;
|
||
}
|
||
return (
|
||
patch.prompt != null ||
|
||
patch.loras ||
|
||
patch.width != null ||
|
||
patch.height != null ||
|
||
patch.aspect != null ||
|
||
patch.steps != null ||
|
||
patch.cfg != null ||
|
||
patch.seed != null ||
|
||
patch.sigma_shift != null ||
|
||
patch.images != null ||
|
||
patch.batch != null ||
|
||
patch.vary === true ||
|
||
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;
|
||
return new Promise((resolve) => {
|
||
const start = Date.now();
|
||
state.waitImageTimer = setInterval(() => {
|
||
if (epoch !== state.chatEpoch) {
|
||
cancelWaitForNewImage();
|
||
resolve(null);
|
||
return;
|
||
}
|
||
const src = findCurrentGenerateSrc();
|
||
if (src && src !== prevSrc && !looksLikeModelPreview(src)) {
|
||
cancelWaitForNewImage();
|
||
resolve(src);
|
||
} else if (Date.now() - start > timeoutMs) {
|
||
cancelWaitForNewImage();
|
||
resolve(null);
|
||
}
|
||
}, 400);
|
||
});
|
||
}
|
||
|
||
async function runGenerateFromPatch(patch, opts = {}) {
|
||
const force = !!opts.force;
|
||
if ((!force && !$('sa_auto_generate')?.checked) || !patchHasGenTrigger(patch)) {
|
||
return null;
|
||
}
|
||
const prev = findCurrentGenerateSrc();
|
||
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 = '<span class="sa-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="sa-typing-label">Waiting for the model…</span>';
|
||
div.appendChild(body);
|
||
box.appendChild(div);
|
||
scrollMessagesToBottom({ force: true });
|
||
state.streamEl = div;
|
||
state.streamMeta = meta || null;
|
||
state.streamFenceDone = false;
|
||
return div;
|
||
}
|
||
|
||
function streamHasClosedPatchFence(text) {
|
||
const t = String(text || '');
|
||
if (!/```[\s\S]*```/.test(t)) {
|
||
return false;
|
||
}
|
||
const { 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;
|
||
}
|
||
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();
|
||
}
|
||
renderPersonaControls(data.controls || {}, data.control_values || data.exact?.controls || {});
|
||
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": <number> }. Без generate, если не просили картинку.`,
|
||
});
|
||
setStatus('/horny-game…');
|
||
}
|
||
|
||
function savePersonaControls(partial) {
|
||
const persona = $('sa_persona')?.value || 'neutral';
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
// Optimistic local merge so UI / next chat see the new values immediately.
|
||
if (partial && typeof partial === 'object') {
|
||
if (state.config) {
|
||
state.config.control_values = { ...(state.config.control_values || {}), ...partial };
|
||
}
|
||
if (state.exact) {
|
||
state.exact.controls = { ...(state.exact.controls || {}), ...partial };
|
||
}
|
||
}
|
||
genericRequest(
|
||
'AssistentSaveControls',
|
||
{ persona, controls: partial || {} },
|
||
(data) => {
|
||
if (data?.error) {
|
||
setStatus(data.error);
|
||
return;
|
||
}
|
||
if (data?.control_values && state.config) {
|
||
state.config.control_values = data.control_values;
|
||
if (state.exact) {
|
||
state.exact.controls = data.control_values;
|
||
}
|
||
}
|
||
// Do not rebuild slider DOM here — that interrupts an in-progress drag and snaps values back.
|
||
// Update live inputs in place if present and not being dragged.
|
||
if (!controlsPointerDown && data?.control_values) {
|
||
syncPersonaControlInputs(data.control_values);
|
||
}
|
||
},
|
||
0,
|
||
() => setStatus('controls save failed'),
|
||
);
|
||
}
|
||
|
||
function syncPersonaControlInputs(values) {
|
||
const box = $('sa_persona_controls');
|
||
if (!box || !values || typeof values !== 'object') {
|
||
return;
|
||
}
|
||
box.querySelectorAll('input[data-control-id]').forEach((input) => {
|
||
const id = input.dataset.controlId;
|
||
if (values[id] == null) {
|
||
return;
|
||
}
|
||
const v = Number(values[id]);
|
||
if (!Number.isFinite(v) || input.value === String(v)) {
|
||
return;
|
||
}
|
||
input.value = String(v);
|
||
const valEl = input.parentElement?.querySelector('.sa-control-val');
|
||
if (valEl) {
|
||
const schema = state.config?.controls?.[id];
|
||
const asPercent = String(schema?.display || '').toLowerCase() === 'percent';
|
||
valEl.textContent = asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function deleteCurrentOverlayPersona() {
|
||
const id = $('sa_persona')?.value;
|
||
if (!id) {
|
||
return;
|
||
}
|
||
const meta = (state.personas || []).find((p) => p.id === id);
|
||
const title = meta?.title || id;
|
||
const src = meta?.source || state.config?.persona_source || '';
|
||
if (src !== 'overlay' && src !== 'overlay+bundled') {
|
||
setStatus('Bundled personas cannot be deleted');
|
||
return;
|
||
}
|
||
if (!window.confirm(`Удалить «${title}»?\nПоставка (bundled) не трогается.`)) {
|
||
return;
|
||
}
|
||
await new Promise((resolve) => {
|
||
genericRequest(
|
||
'AssistentDeletePersona',
|
||
{ persona: id },
|
||
async (data) => {
|
||
if (data?.error) {
|
||
setStatus(data.error);
|
||
resolve();
|
||
return;
|
||
}
|
||
const next = data?.default_persona || 'neutral';
|
||
if (Array.isArray(data?.personas)) {
|
||
state.personas = data.personas;
|
||
}
|
||
renderPersonaOptions(state.personas || [], next);
|
||
if ($('sa_persona')) {
|
||
$('sa_persona').value = next;
|
||
}
|
||
await applyPersonaForChat(next, { quiet: false });
|
||
setStatus(`Удалено: ${id}`);
|
||
resolve();
|
||
},
|
||
0,
|
||
() => { setStatus('delete failed'); resolve(); },
|
||
);
|
||
});
|
||
}
|
||
|
||
function renderPersonaOptions(personas, selected) {
|
||
const sel = $('sa_persona');
|
||
if (!sel) {
|
||
return;
|
||
}
|
||
const cur = selected || sel.value || localStorage.getItem(LS_PERSONA) || 'neutral';
|
||
sel.innerHTML = '';
|
||
for (const p of personas) {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = p.title || p.id;
|
||
if (p.accent) {
|
||
opt.dataset.accent = p.accent;
|
||
}
|
||
sel.appendChild(opt);
|
||
}
|
||
if ([...sel.options].some((o) => o.value === cur)) {
|
||
sel.value = cur;
|
||
}
|
||
const meta = (personas || []).find((p) => p.id === sel.value);
|
||
syncPersonaDeleteButton(meta?.source || state.config?.persona_source);
|
||
}
|
||
|
||
function renderPackOptions(packs, preferred) {
|
||
const sel = $('sa_pack');
|
||
if (!sel) {
|
||
return;
|
||
}
|
||
const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || defaultPackId();
|
||
sel.innerHTML = '';
|
||
const list = (packs || []).slice().sort((a, b) => (a.order || 100) - (b.order || 100));
|
||
for (const p of list) {
|
||
const opt = document.createElement('option');
|
||
opt.value = p.id;
|
||
opt.textContent = p.title || p.id;
|
||
sel.appendChild(opt);
|
||
}
|
||
if ([...sel.options].some((o) => o.value === cur)) {
|
||
sel.value = cur;
|
||
}
|
||
}
|
||
|
||
function renderChips(chips) {
|
||
const box = $('sa_chips');
|
||
if (!box || !Array.isArray(chips) || !chips.length) {
|
||
return;
|
||
}
|
||
box.innerHTML = '';
|
||
for (const c of chips) {
|
||
if (c.sep) {
|
||
const sep = document.createElement('span');
|
||
sep.className = 'sa-chip-sep';
|
||
sep.setAttribute('aria-hidden', 'true');
|
||
box.appendChild(sep);
|
||
continue;
|
||
}
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'sa-chip';
|
||
btn.textContent = c.label || c.value || '';
|
||
if (c.title) {
|
||
btn.title = c.title;
|
||
}
|
||
const action = c.action || '';
|
||
const value = c.value ?? '';
|
||
if (action === 'aspect') {
|
||
btn.setAttribute('data-aspect', value);
|
||
} else if (action === 'seed') {
|
||
btn.setAttribute('data-seed', value);
|
||
} else if (action === 'vary') {
|
||
btn.setAttribute('data-vary', value || '1');
|
||
} else if (action === 'krea_profile') {
|
||
btn.setAttribute('data-krea-profile', value);
|
||
}
|
||
box.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function renderSkillChecks(skills, enabled) {
|
||
const box = $('sa_skills_box');
|
||
if (!box) {
|
||
return;
|
||
}
|
||
const on = new Set(enabled || []);
|
||
box.innerHTML = '';
|
||
for (const s of skills || []) {
|
||
const label = document.createElement('label');
|
||
label.className = 'sa-check';
|
||
const input = document.createElement('input');
|
||
input.type = 'checkbox';
|
||
input.setAttribute('data-skill', s.id);
|
||
input.checked = on.has(s.id) || (!enabled?.length && !!s.default);
|
||
input.addEventListener('change', () => {
|
||
state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
|
||
saveSettings();
|
||
});
|
||
label.appendChild(input);
|
||
label.appendChild(document.createTextNode(` ${s.title || s.id}`));
|
||
box.appendChild(label);
|
||
}
|
||
state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
|
||
}
|
||
|
||
function loadConfig(persona, done) {
|
||
if (typeof genericRequest !== 'function') {
|
||
done?.(null);
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentGetConfig',
|
||
{ persona: persona || $('sa_persona')?.value || 'neutral' },
|
||
(data) => {
|
||
applyConfigPayload(data, { applyDefaults: true });
|
||
done?.(data);
|
||
},
|
||
0,
|
||
() => done?.(null),
|
||
);
|
||
}
|
||
|
||
/** Larger param tags win (32b > 8b > 7b); instruct / qwen3 preferred over thinking/:latest. */
|
||
function chatModelSeniority(name) {
|
||
const n = String(name || '').toLowerCase();
|
||
let score = 0;
|
||
const m = n.match(/(?:^|[:\-/])(\d+)\s*b\b/);
|
||
if (m) {
|
||
score += Number(m[1]) * 1e6;
|
||
}
|
||
if (n.includes('instruct')) {
|
||
score += 5e4;
|
||
}
|
||
if (n.includes('qwen3')) {
|
||
score += 2e4;
|
||
}
|
||
if (n.includes('thinking') || n.endsWith(':latest')) {
|
||
score -= 1e4;
|
||
}
|
||
return score;
|
||
}
|
||
|
||
function pickSeniorChatModel(names) {
|
||
const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
|
||
if (!list.length) {
|
||
return '';
|
||
}
|
||
return [...list].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b))[0];
|
||
}
|
||
|
||
/**
|
||
* Resolve which chat tag to select / warm.
|
||
* Priority: api preferred (ollama-roles default_chat) → senior heuristic → LS (after one-shot junior upgrade).
|
||
*/
|
||
function resolveChatModel(names, apiPreferred) {
|
||
const list = (names || []).map((n) => String(n || '').trim()).filter(Boolean);
|
||
if (!list.length) {
|
||
return '';
|
||
}
|
||
const preferred = apiPreferred && list.includes(apiPreferred)
|
||
? apiPreferred
|
||
: pickSeniorChatModel(list);
|
||
const ls = state.preferredModel || localStorage.getItem(LS_MODEL) || '';
|
||
if (!localStorage.getItem(LS_MODEL_SENIOR_MIG)) {
|
||
localStorage.setItem(LS_MODEL_SENIOR_MIG, '1');
|
||
if (preferred && (!ls || !list.includes(ls) || chatModelSeniority(ls) < chatModelSeniority(preferred))) {
|
||
state.preferredModel = preferred;
|
||
return preferred;
|
||
}
|
||
}
|
||
if (ls && list.includes(ls)) {
|
||
return ls;
|
||
}
|
||
return preferred || list[0];
|
||
}
|
||
|
||
function setModelOptions(models, { error, preferred } = {}) {
|
||
const sel = $('sa_model');
|
||
const sel2 = $('sa_settings_chat_model');
|
||
const apply = (target) => {
|
||
if (!target) {
|
||
return;
|
||
}
|
||
let names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
|
||
names = [...names].sort((a, b) => chatModelSeniority(b) - chatModelSeniority(a) || a.localeCompare(b));
|
||
target.innerHTML = '';
|
||
if (error) {
|
||
const opt = document.createElement('option');
|
||
opt.value = '';
|
||
opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
|
||
target.appendChild(opt);
|
||
target.disabled = true;
|
||
return;
|
||
}
|
||
target.disabled = false;
|
||
if (!names.length) {
|
||
const opt = document.createElement('option');
|
||
opt.value = '';
|
||
opt.textContent = 'No Ollama models — pull / Refresh';
|
||
target.appendChild(opt);
|
||
return;
|
||
}
|
||
for (const name of names) {
|
||
const opt = document.createElement('option');
|
||
opt.value = name;
|
||
opt.textContent = name;
|
||
target.appendChild(opt);
|
||
}
|
||
const pick = resolveChatModel(names, preferred);
|
||
if (pick) {
|
||
target.value = pick;
|
||
}
|
||
};
|
||
apply(sel);
|
||
apply(sel2);
|
||
}
|
||
|
||
function setEmbedModelOptions(models) {
|
||
const sel = $('sa_embed_model');
|
||
if (!sel) {
|
||
return;
|
||
}
|
||
const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
|
||
sel.innerHTML = '';
|
||
if (!names.length) {
|
||
const opt = document.createElement('option');
|
||
opt.value = state.preferredEmbed || 'nomic-embed-text';
|
||
opt.textContent = opt.value + ' (ожидается pull)';
|
||
sel.appendChild(opt);
|
||
return;
|
||
}
|
||
for (const name of names) {
|
||
const opt = document.createElement('option');
|
||
opt.value = name;
|
||
opt.textContent = name;
|
||
sel.appendChild(opt);
|
||
}
|
||
const prefer = state.preferredEmbed || localStorage.getItem(LS_EMBED) || state.config?.assistant?.embed_model;
|
||
if (prefer && names.includes(prefer)) {
|
||
sel.value = prefer;
|
||
} else if (prefer && !names.includes(prefer)) {
|
||
const opt = document.createElement('option');
|
||
opt.value = prefer;
|
||
opt.textContent = prefer;
|
||
sel.appendChild(opt);
|
||
sel.value = prefer;
|
||
}
|
||
}
|
||
|
||
function refreshModels() {
|
||
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
|
||
setStatus('Loading models…');
|
||
if (typeof genericRequest !== 'function') {
|
||
setStatus('SwarmUI API not ready');
|
||
setModelOptions([], { error: 'SwarmUI API not ready' });
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentListModels',
|
||
{ baseUrl },
|
||
(data) => {
|
||
const models = data.models || [];
|
||
const memoryModels = data.memory_models || [];
|
||
const preferred = (data.preferred || '').trim();
|
||
setModelOptions(models, { preferred });
|
||
setEmbedModelOptions(memoryModels);
|
||
const pick = resolveChatModel(models, preferred);
|
||
if (pick && $('sa_model')) {
|
||
$('sa_model').value = pick;
|
||
if ($('sa_settings_chat_model')) {
|
||
$('sa_settings_chat_model').value = pick;
|
||
}
|
||
state.preferredModel = pick;
|
||
localStorage.setItem(LS_MODEL, pick);
|
||
}
|
||
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
|
||
if (models.length) {
|
||
setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`);
|
||
} else {
|
||
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
|
||
}
|
||
saveSettings();
|
||
},
|
||
0,
|
||
(err) => {
|
||
const msg = String(err || 'Ollama unreachable');
|
||
setStatus(msg);
|
||
setModelOptions([], { error: msg });
|
||
setOllamaHealth('down', 'Ollama ✕', msg);
|
||
appendMessage('error', msg);
|
||
},
|
||
);
|
||
}
|
||
|
||
function refreshInventory(done, opts = {}) {
|
||
if (typeof genericRequest !== 'function') {
|
||
if (done) {
|
||
done();
|
||
}
|
||
return;
|
||
}
|
||
const rescan = !!opts.rescan;
|
||
genericRequest(
|
||
'AssistentListInventory',
|
||
{ rescan },
|
||
(data) => {
|
||
state.inventory = {
|
||
loras: data.loras || [],
|
||
checkpoints: data.checkpoints || [],
|
||
wildcards: data.wildcards || [],
|
||
has_civitai_key: !!data.has_civitai_key,
|
||
inventory_at: data.inventory_at || Math.floor(Date.now() / 1000),
|
||
rescanned: !!data.rescanned,
|
||
};
|
||
state.inventoryFetchedAt = Date.now();
|
||
const n = state.inventory.loras.length;
|
||
const ck = state.inventory.checkpoints.length;
|
||
setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? ' (rescanned)' : ''}`);
|
||
prefetchActiveModelCards();
|
||
if (state.view === 'cards') {
|
||
renderCardsList();
|
||
}
|
||
if (done) {
|
||
done(state.inventory);
|
||
}
|
||
},
|
||
0,
|
||
(err) => {
|
||
console.warn('Assistent inventory', err);
|
||
if (done) {
|
||
done(null);
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
function refreshInventoryAsync(opts = {}) {
|
||
return new Promise((resolve) => refreshInventory(resolve, opts));
|
||
}
|
||
|
||
function memoryKindFilter() {
|
||
return $('sa_mem_kind')?.value || 'all';
|
||
}
|
||
|
||
function memoryScopeFilter() {
|
||
return $('sa_mem_scope')?.value || 'all';
|
||
}
|
||
|
||
function memorySearchFilter() {
|
||
return ($('sa_mem_search')?.value || '').trim().toLowerCase();
|
||
}
|
||
|
||
function renderMemoryKinds(kinds) {
|
||
const sel = $('sa_mem_kind');
|
||
if (!sel) {
|
||
return;
|
||
}
|
||
const cur = sel.value || 'all';
|
||
sel.innerHTML = '';
|
||
const all = document.createElement('option');
|
||
all.value = 'all';
|
||
all.textContent = 'Все типы';
|
||
sel.appendChild(all);
|
||
for (const kind of kinds || []) {
|
||
const opt = document.createElement('option');
|
||
opt.value = kind;
|
||
opt.textContent = kind;
|
||
sel.appendChild(opt);
|
||
}
|
||
if ([...sel.options].some((o) => o.value === cur)) {
|
||
sel.value = cur;
|
||
}
|
||
}
|
||
|
||
function filteredMemoryRows() {
|
||
const filter = memoryKindFilter();
|
||
const scope = memoryScopeFilter();
|
||
const q = memorySearchFilter();
|
||
const persona = $('sa_persona')?.value || 'neutral';
|
||
return (state.memoryRows || []).filter((m) => {
|
||
if (filter !== 'all' && m.kind !== filter) {
|
||
return false;
|
||
}
|
||
if (scope === 'shared' && m.scope !== 'shared') {
|
||
return false;
|
||
}
|
||
if (scope === 'personal' && !(m.scope === 'personal' && (m.persona === persona || !m.persona))) {
|
||
return false;
|
||
}
|
||
if (q) {
|
||
const hay = `${m.kind || ''} ${m.key || ''} ${m.text || ''}`.toLowerCase();
|
||
if (!hay.includes(q)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function renderMemoryList() {
|
||
const root = $('sa_mem_list');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
const rows = filteredMemoryRows();
|
||
root.innerHTML = '';
|
||
if (!rows.length) {
|
||
root.innerHTML = '<div class="sa-mem-empty">Крафт-память пуста — карточки, seed и патчи <code>memory_upsert</code>.</div>';
|
||
return;
|
||
}
|
||
for (const row of rows) {
|
||
const el = document.createElement('div');
|
||
el.className = 'sa-mem-row';
|
||
const bundled = row.source === 'bundled';
|
||
const when = row.updated ? formatChatWhen(row.updated * 1000) : '';
|
||
const scope = row.scope === 'personal' ? `персона ${row.persona || '—'}` : 'общая';
|
||
el.innerHTML = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-kind-badge">${escapeHtml(row.kind || 'note')}</span><span class="sa-mem-row-key">${escapeHtml(row.key || '')}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 220))}</div><div class="sa-mem-row-meta">${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}</div></div>`;
|
||
const forget = document.createElement('button');
|
||
forget.type = 'button';
|
||
forget.className = 'basic-button sa-mem-forget';
|
||
forget.textContent = '×';
|
||
if (bundled) {
|
||
forget.disabled = true;
|
||
forget.title = 'Bundled — вернётся при reseed, правь Config/_base/memory-seed/';
|
||
} else {
|
||
forget.title = 'Забыть';
|
||
forget.addEventListener('click', () => forgetMemory(row));
|
||
}
|
||
el.appendChild(forget);
|
||
root.appendChild(el);
|
||
}
|
||
}
|
||
|
||
function refreshMemoryList() {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
const list = $('sa_mem_list');
|
||
if (list && !state.memoryRows.length) {
|
||
list.innerHTML = '<div class="sa-mem-empty">Читаю память…</div>';
|
||
}
|
||
genericRequest(
|
||
'AssistentListMemory',
|
||
{ limit: 200 },
|
||
(data) => {
|
||
state.memoryRows = Array.isArray(data?.memories) ? data.memories : [];
|
||
renderMemoryKinds(data?.kinds || []);
|
||
renderMemoryList();
|
||
const foot = $('sa_mem_total');
|
||
if (foot) {
|
||
foot.textContent = `Всего: ${data?.total ?? state.memoryRows.length} · ${data?.embed_model || '—'}`;
|
||
}
|
||
},
|
||
0,
|
||
(err) => {
|
||
if (list) {
|
||
list.innerHTML = `<div class="sa-mem-empty">Память недоступна: ${escapeHtml(String(err || 'ошибка'))}</div>`;
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
function forgetMemory(row) {
|
||
if (!row?.kind || !row?.key || typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentForgetMemory',
|
||
{
|
||
kind: row.kind,
|
||
key: row.key,
|
||
source: row.source || '',
|
||
scope: row.scope || '',
|
||
persona: row.scope === 'personal' ? (row.persona || '') : '',
|
||
},
|
||
() => {
|
||
state.memoryRows = (state.memoryRows || []).filter((m) => !(m.kind === row.kind && m.key === row.key && m.source === row.source && m.persona === row.persona));
|
||
renderMemoryList();
|
||
setStatus(`Забыто: ${row.kind}/${row.key}`);
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Не удалось забыть')),
|
||
);
|
||
}
|
||
|
||
function clearCraftMemory(opts = {}) {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
const label = opts.label || 'крафт-память';
|
||
if (!window.confirm(`Очистить ${label}? Bundled seed останется.`)) {
|
||
return;
|
||
}
|
||
const body = {
|
||
scope: opts.scope || '',
|
||
kind: opts.kind || '',
|
||
persona: opts.persona || '',
|
||
};
|
||
genericRequest(
|
||
'AssistentClearMemory',
|
||
body,
|
||
(data) => {
|
||
setStatus(`Удалено крафт-записей: ${data?.deleted ?? 0}`);
|
||
refreshMemoryList();
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Очистка не удалась')),
|
||
);
|
||
}
|
||
|
||
function setSettingsTab(id) {
|
||
state.settingsTab = id || 'behavior';
|
||
document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
|
||
const on = btn.getAttribute('data-stab') === state.settingsTab;
|
||
btn.classList.toggle('sa-stab-active', on);
|
||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||
});
|
||
document.querySelectorAll('#sa_settings .sa-spane').forEach((pane) => {
|
||
pane.hidden = pane.getAttribute('data-spane') !== state.settingsTab;
|
||
});
|
||
if (state.settingsTab === 'craft') {
|
||
refreshMemoryList();
|
||
refreshWantedQueue();
|
||
}
|
||
if (state.settingsTab === 'user') {
|
||
refreshUserPrefs();
|
||
}
|
||
if (state.settingsTab === 'personas') {
|
||
renderPersonaSettingsList();
|
||
}
|
||
if (state.settingsTab === 'models') {
|
||
syncSettingsHealthLine();
|
||
const m = $('sa_model')?.value;
|
||
if (m && $('sa_settings_chat_model')) {
|
||
$('sa_settings_chat_model').value = m;
|
||
}
|
||
}
|
||
if (state.settingsTab === 'more') {
|
||
fillKnobsFromConfig(state.config);
|
||
}
|
||
}
|
||
|
||
function fillKnobsFromConfig(data) {
|
||
const asst = data?.assistant || state.config?.assistant || {};
|
||
const exact = data?.exact || state.config?.exact || state.exact || {};
|
||
const setNum = (id, v) => {
|
||
const el = $(id);
|
||
if (el && v != null && Number.isFinite(Number(v))) {
|
||
el.value = String(v);
|
||
}
|
||
};
|
||
setNum('sa_num_ctx', asst.num_ctx);
|
||
setNum('sa_history_keep', asst.history_keep_turns);
|
||
setNum('sa_memory_top_k', asst.memory_top_k);
|
||
const w = asst.user_prefs_weight != null ? Number(asst.user_prefs_weight) : 1;
|
||
const weightEl = $('sa_user_prefs_weight');
|
||
if (weightEl) {
|
||
weightEl.value = String(Math.max(0, Math.min(1.5, w)));
|
||
const lab = $('sa_user_prefs_weight_val');
|
||
if (lab) {
|
||
lab.textContent = Number(weightEl.value).toFixed(1);
|
||
}
|
||
}
|
||
const turbo = exact.profiles?.turbo || {};
|
||
const raw = exact.profiles?.raw || {};
|
||
setNum('sa_exact_turbo_steps', turbo.steps);
|
||
setNum('sa_exact_turbo_cfg', turbo.cfg);
|
||
setNum('sa_exact_turbo_sigma', turbo.sigma_shift);
|
||
setNum('sa_exact_raw_steps', raw.steps);
|
||
setNum('sa_exact_raw_cfg', raw.cfg);
|
||
setNum('sa_exact_raw_sigma', raw.sigma_shift);
|
||
}
|
||
|
||
function saveKnobs() {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
const num = (id) => {
|
||
const v = parseFloat($(id)?.value);
|
||
return Number.isFinite(v) ? v : null;
|
||
};
|
||
const assistant = {
|
||
num_ctx: num('sa_num_ctx'),
|
||
history_keep_turns: num('sa_history_keep'),
|
||
memory_top_k: num('sa_memory_top_k'),
|
||
user_prefs_weight: num('sa_user_prefs_weight'),
|
||
};
|
||
Object.keys(assistant).forEach((k) => {
|
||
if (assistant[k] == null) {
|
||
delete assistant[k];
|
||
}
|
||
});
|
||
const exact = {
|
||
profiles: {
|
||
turbo: {
|
||
steps: num('sa_exact_turbo_steps'),
|
||
cfg: num('sa_exact_turbo_cfg'),
|
||
sigma_shift: num('sa_exact_turbo_sigma'),
|
||
},
|
||
raw: {
|
||
steps: num('sa_exact_raw_steps'),
|
||
cfg: num('sa_exact_raw_cfg'),
|
||
sigma_shift: num('sa_exact_raw_sigma'),
|
||
},
|
||
},
|
||
};
|
||
genericRequest(
|
||
'AssistentSaveKnobs',
|
||
{ assistant, exact },
|
||
(data) => {
|
||
if (data?.assistant || data?.exact) {
|
||
applyConfigPayload({
|
||
...state.config,
|
||
assistant: data.assistant || state.config?.assistant,
|
||
exact: data.exact || state.config?.exact,
|
||
});
|
||
}
|
||
setStatus('Knobs сохранены в overlay');
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Не удалось сохранить knobs')),
|
||
);
|
||
}
|
||
|
||
function syncSettingsHealthLine() {
|
||
const line = $('sa_settings_health_line');
|
||
const badge = $('sa_ollama_health');
|
||
if (line && badge) {
|
||
line.textContent = badge.textContent || 'Ollama · …';
|
||
line.className = 'sa-settings-health ' + (badge.className || '').replace('sa-health', '').trim();
|
||
}
|
||
}
|
||
|
||
function personaSourceLabel(source) {
|
||
if (source === 'overlay') {
|
||
return 'моя';
|
||
}
|
||
if (source === 'overlay+bundled') {
|
||
return 'встроено+правка';
|
||
}
|
||
return 'встроено';
|
||
}
|
||
|
||
function renderPersonaSettingsList() {
|
||
const root = $('sa_persona_list');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
const list = state.personas || state.config?.personas || [];
|
||
const cur = state.settingsPersonaId || $('sa_persona')?.value || list[0]?.id;
|
||
state.settingsPersonaId = cur;
|
||
root.innerHTML = '';
|
||
for (const p of list) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'sa-persona-item' + (p.id === cur ? ' sa-persona-item-active' : '');
|
||
const accent = p.accent || 'currentColor';
|
||
btn.innerHTML = `<div class="sa-persona-item-title"><span class="sa-persona-dot" style="background:${escapeHtml(accent)}"></span><span>${escapeHtml(p.title || p.id)}</span></div><div class="sa-persona-badge">${escapeHtml(personaSourceLabel(p.source))}</div>`;
|
||
btn.addEventListener('click', () => {
|
||
state.settingsPersonaId = p.id;
|
||
renderPersonaSettingsList();
|
||
loadPersonaPreview(p.id);
|
||
});
|
||
root.appendChild(btn);
|
||
}
|
||
syncPersonaPanelActions();
|
||
if (cur) {
|
||
loadPersonaPreview(cur);
|
||
}
|
||
}
|
||
|
||
function syncPersonaPanelActions() {
|
||
const id = state.settingsPersonaId;
|
||
const p = (state.personas || []).find((x) => x.id === id);
|
||
const canDelete = p && (p.source === 'overlay' || p.source === 'overlay+bundled');
|
||
const del = $('sa_btn_persona_delete_panel');
|
||
if (del) {
|
||
del.disabled = !canDelete;
|
||
}
|
||
}
|
||
|
||
function loadPersonaPreview(id) {
|
||
const box = $('sa_persona_preview');
|
||
if (!box || typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
box.innerHTML = '<div class="sa-mem-empty">Загрузка…</div>';
|
||
genericRequest(
|
||
'AssistentGetPersonaShelves',
|
||
{ persona: id },
|
||
(data) => {
|
||
const summary = data?.identity_summary || '';
|
||
const src = data?.source || '';
|
||
box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
|
||
syncPersonaPanelActions();
|
||
},
|
||
0,
|
||
(err) => {
|
||
box.innerHTML = `<div class="sa-mem-empty">${escapeHtml(String(err || 'ошибка'))}</div>`;
|
||
},
|
||
);
|
||
}
|
||
|
||
function exportSelectedPersona() {
|
||
const id = state.settingsPersonaId || $('sa_persona')?.value;
|
||
if (!id || typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentExportPersona',
|
||
{ persona: id },
|
||
(data) => {
|
||
const pack = data?.pack;
|
||
if (!pack) {
|
||
setStatus('Пустой экспорт');
|
||
return;
|
||
}
|
||
const blob = new Blob([JSON.stringify(pack, null, 2)], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${pack.id || id}.assistent-persona.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
setStatus(`Экспорт: ${a.download}`);
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Экспорт не удался')),
|
||
);
|
||
}
|
||
|
||
function importPersonaFile(file) {
|
||
if (!file || typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
let pack;
|
||
try {
|
||
pack = JSON.parse(String(reader.result || ''));
|
||
} catch (e) {
|
||
setStatus('Невалидный JSON');
|
||
return;
|
||
}
|
||
let newId = pack?.id || '';
|
||
if ((state.personas || []).some((p) => p.id === newId && (p.source === 'bundled' || p.source === 'overlay+bundled'))) {
|
||
newId = window.prompt('Id занят bundled — новый id:', `${newId}_import`) || '';
|
||
}
|
||
genericRequest(
|
||
'AssistentImportPersona',
|
||
{ pack, new_id: newId || null, overwrite: false },
|
||
(data) => {
|
||
if (Array.isArray(data?.personas)) {
|
||
state.personas = data.personas;
|
||
renderPersonaOptions(data.personas, data.persona?.id);
|
||
}
|
||
state.settingsPersonaId = data?.persona?.id || newId;
|
||
renderPersonaSettingsList();
|
||
setStatus(`Импорт: ${data?.persona?.id || newId}`);
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Импорт не удался')),
|
||
);
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function cloneSelectedPersona() {
|
||
const from = state.settingsPersonaId || $('sa_persona')?.value;
|
||
if (!from) {
|
||
return;
|
||
}
|
||
const to = window.prompt('Новый id личности:', `${from}_copy`);
|
||
if (!to) {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentClonePersona',
|
||
{ from, to, title: to },
|
||
(data) => {
|
||
if (Array.isArray(data?.personas)) {
|
||
state.personas = data.personas;
|
||
renderPersonaOptions(data.personas, to);
|
||
}
|
||
state.settingsPersonaId = to;
|
||
renderPersonaSettingsList();
|
||
setStatus(`Клон: ${to}`);
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Клон не удался')),
|
||
);
|
||
}
|
||
|
||
function deleteSelectedOverlayPersona() {
|
||
const id = state.settingsPersonaId;
|
||
const p = (state.personas || []).find((x) => x.id === id);
|
||
if (!p || (p.source !== 'overlay' && p.source !== 'overlay+bundled')) {
|
||
setStatus('Можно удалить только overlay');
|
||
return;
|
||
}
|
||
if (!window.confirm(`Удалить overlay-личность «${id}»?`)) {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentDeletePersona',
|
||
{ persona: id },
|
||
(data) => {
|
||
if (Array.isArray(data?.personas)) {
|
||
state.personas = data.personas;
|
||
renderPersonaOptions(data.personas, data.default_persona);
|
||
}
|
||
state.settingsPersonaId = data?.default_persona || null;
|
||
renderPersonaSettingsList();
|
||
setStatus(`Удалено: ${id}`);
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Удаление не удалось')),
|
||
);
|
||
}
|
||
|
||
function refreshUserPrefs() {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
const persona = $('sa_persona')?.value || 'neutral';
|
||
genericRequest(
|
||
'AssistentListUserPrefs',
|
||
{ persona, limit: 200 },
|
||
(data) => {
|
||
state.userPrefs = Array.isArray(data?.prefs) ? data.prefs : [];
|
||
renderUserPrefsLists();
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'User prefs недоступны')),
|
||
);
|
||
}
|
||
|
||
function renderUserPrefsLists() {
|
||
const persona = $('sa_persona')?.value || 'neutral';
|
||
const global = (state.userPrefs || []).filter((p) => p.scope === 'global');
|
||
const personal = (state.userPrefs || []).filter((p) => p.scope === 'persona' && (p.persona_id === persona || p.persona === persona));
|
||
const fill = (rootId, rows) => {
|
||
const root = $(rootId);
|
||
if (!root) {
|
||
return;
|
||
}
|
||
root.innerHTML = '';
|
||
if (!rows.length) {
|
||
root.innerHTML = '<div class="sa-mem-empty">Пусто</div>';
|
||
return;
|
||
}
|
||
for (const row of rows) {
|
||
const el = document.createElement('div');
|
||
el.className = 'sa-mem-row';
|
||
const pin = row.pinned ? ' ★' : '';
|
||
el.innerHTML = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-row-key">${escapeHtml(row.key || '')}${pin}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 200))}</div></div>`;
|
||
el.querySelector('.sa-mem-row-body')?.addEventListener('click', () => editUserPref(row));
|
||
el.querySelector('.sa-mem-row-body')?.setAttribute('title', 'Клик — редактировать');
|
||
const pinBtn = document.createElement('button');
|
||
pinBtn.type = 'button';
|
||
pinBtn.className = 'basic-button sa-mem-forget';
|
||
pinBtn.textContent = row.pinned ? '★' : '☆';
|
||
pinBtn.title = row.pinned ? 'Unpin' : 'Pin';
|
||
pinBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
toggleUserPrefPin(row);
|
||
});
|
||
el.appendChild(pinBtn);
|
||
const forget = document.createElement('button');
|
||
forget.type = 'button';
|
||
forget.className = 'basic-button sa-mem-forget';
|
||
forget.textContent = '×';
|
||
forget.title = 'Забыть';
|
||
forget.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
forgetUserPref(row);
|
||
});
|
||
el.appendChild(forget);
|
||
root.appendChild(el);
|
||
}
|
||
};
|
||
fill('sa_prefs_global', global);
|
||
fill('sa_prefs_persona', personal);
|
||
}
|
||
|
||
function editUserPref(row) {
|
||
const text = window.prompt('Текст факта:', row.text || '');
|
||
if (text == null || !String(text).trim()) {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentUpsertUserPref',
|
||
{
|
||
key: row.key,
|
||
text: String(text).trim(),
|
||
scope: row.scope || 'global',
|
||
persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
|
||
source: 'user',
|
||
pinned: !!row.pinned,
|
||
},
|
||
() => refreshUserPrefs(),
|
||
0,
|
||
(err) => setStatus(String(err || 'Не удалось сохранить')),
|
||
);
|
||
}
|
||
|
||
function toggleUserPrefPin(row) {
|
||
genericRequest(
|
||
'AssistentUpsertUserPref',
|
||
{
|
||
key: row.key,
|
||
text: row.text,
|
||
scope: row.scope || 'global',
|
||
persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
|
||
source: row.source || 'user',
|
||
pinned: !row.pinned,
|
||
},
|
||
() => refreshUserPrefs(),
|
||
0,
|
||
(err) => setStatus(String(err || 'Не удалось pin')),
|
||
);
|
||
}
|
||
|
||
function addUserPref(scope) {
|
||
const key = window.prompt('Ключ (stable-id):', scope === 'global' ? 'prefer' : 'tone');
|
||
if (!key) {
|
||
return;
|
||
}
|
||
const text = window.prompt('Текст факта:', '');
|
||
if (!text) {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentUpsertUserPref',
|
||
{
|
||
key: key.trim(),
|
||
text: text.trim(),
|
||
scope,
|
||
persona: $('sa_persona')?.value || 'neutral',
|
||
source: 'user',
|
||
pinned: false,
|
||
},
|
||
() => {
|
||
refreshUserPrefs();
|
||
setStatus('Сохранено');
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Не удалось сохранить')),
|
||
);
|
||
}
|
||
|
||
function forgetUserPref(row) {
|
||
genericRequest(
|
||
'AssistentForgetUserPref',
|
||
{
|
||
key: row.key,
|
||
scope: row.scope || 'global',
|
||
persona: row.persona_id || row.persona || $('sa_persona')?.value,
|
||
},
|
||
() => refreshUserPrefs(),
|
||
0,
|
||
(err) => setStatus(String(err || 'Не удалось забыть')),
|
||
);
|
||
}
|
||
|
||
function clearUserPrefs(scope) {
|
||
const labels = { global: 'общие prefs', persona: 'prefs этой личности', all: 'все prefs о пользователе' };
|
||
if (!window.confirm(`Очистить ${labels[scope] || scope}?`)) {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentClearUserPrefs',
|
||
{ scope, persona: $('sa_persona')?.value || 'neutral' },
|
||
(data) => {
|
||
setStatus(`Удалено: ${data?.deleted ?? 0}`);
|
||
refreshUserPrefs();
|
||
},
|
||
0,
|
||
(err) => setStatus(String(err || 'Очистка не удалась')),
|
||
);
|
||
}
|
||
|
||
function resetUiState() {
|
||
if (!window.confirm('Сбросить UI-state (local + disk)? Настройки Ollama и prefs останутся.')) {
|
||
return;
|
||
}
|
||
const keys = Object.keys(localStorage).filter((k) => k.startsWith('swarm_assistent_'));
|
||
for (const k of keys) {
|
||
if (k === LS_TASTE) {
|
||
continue;
|
||
}
|
||
localStorage.removeItem(k);
|
||
}
|
||
diskPersist()?.saveUiState?.({});
|
||
setStatus('UI-state сброшен — обнови страницу');
|
||
}
|
||
|
||
function modelKeyLeaf(name) {
|
||
return String(name || '')
|
||
.replace(/\\/g, '/')
|
||
.split('/')
|
||
.pop()
|
||
.replace(/\.(safetensors|ckpt|pt|pth|gguf|bin)$/i, '')
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function refreshWantedQueue() {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentListWanted',
|
||
{},
|
||
(data) => {
|
||
const items = Array.isArray(data?.items) ? data.items : [];
|
||
state.wanted = { count: data?.count ?? items.length, items };
|
||
const keys = new Set();
|
||
for (const item of items) {
|
||
const leaf = modelKeyLeaf(item?.title);
|
||
if (leaf) {
|
||
keys.add(leaf);
|
||
}
|
||
if (item?.version_id) {
|
||
keys.add(`v${item.version_id}`);
|
||
}
|
||
}
|
||
state.wantedKeys = keys;
|
||
const el = $('sa_mem_wanted');
|
||
if (el) {
|
||
el.textContent = state.wanted.count
|
||
? `Очередь wanted: ${state.wanted.count} (скачается на следующем up)`
|
||
: 'Очередь wanted: пусто';
|
||
el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join('\n');
|
||
}
|
||
if (state.view === 'cards') {
|
||
renderCardsList();
|
||
}
|
||
},
|
||
0,
|
||
() => {
|
||
const el = $('sa_mem_wanted');
|
||
if (el) {
|
||
el.textContent = 'Очередь wanted: —';
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
function isWantedModel(row) {
|
||
const keys = state.wantedKeys;
|
||
if (!keys || !keys.size) {
|
||
return false;
|
||
}
|
||
for (const candidate of [row?.name, row?.title]) {
|
||
const leaf = modelKeyLeaf(candidate);
|
||
if (leaf && keys.has(leaf)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function setOllamaHealth(level, text, title) {
|
||
state.ollamaHealth = level;
|
||
const el = $('sa_ollama_health');
|
||
if (!el) {
|
||
return;
|
||
}
|
||
el.hidden = false;
|
||
el.textContent = text;
|
||
el.title = title || text;
|
||
el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
|
||
el.classList.add(`sa-health-${level}`);
|
||
syncSettingsHealthLine();
|
||
}
|
||
|
||
function probeOllamaHealth() {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
|
||
genericRequest(
|
||
'AssistentListModels',
|
||
{ baseUrl },
|
||
(data) => {
|
||
if (data?.error) {
|
||
setOllamaHealth('down', 'Ollama ✕', String(data.error));
|
||
return;
|
||
}
|
||
const chat = (data.models || []).length;
|
||
const mem = (data.memory_models || []).length;
|
||
if (!chat) {
|
||
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
|
||
return;
|
||
}
|
||
setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
|
||
},
|
||
0,
|
||
(err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`),
|
||
);
|
||
}
|
||
|
||
function setCardStatus(msg) {
|
||
const el = $('sa_card_status');
|
||
if (el) {
|
||
el.textContent = msg || '';
|
||
}
|
||
}
|
||
|
||
function setView(view) {
|
||
if (view === 'cards') {
|
||
state.view = 'cards';
|
||
} else if (view === 'settings') {
|
||
state.view = 'settings';
|
||
} else {
|
||
state.view = 'chat';
|
||
}
|
||
const chat = $('sa_view_chat');
|
||
const cards = $('sa_view_cards');
|
||
const settings = $('sa_view_settings');
|
||
if (chat) {
|
||
chat.hidden = state.view !== 'chat';
|
||
}
|
||
if (cards) {
|
||
cards.hidden = state.view !== 'cards';
|
||
}
|
||
if (settings) {
|
||
settings.hidden = state.view !== 'settings';
|
||
}
|
||
$('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
|
||
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
|
||
$('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
|
||
$('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
|
||
saveSettings();
|
||
if (state.view === 'cards') {
|
||
renderCardsList();
|
||
} else if (state.view === 'settings') {
|
||
setSettingsTab(state.settingsTab || 'behavior');
|
||
} else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
|
||
// Back in the chat — bring the model home (Krea may have evicted it).
|
||
warmLlm({ force: true });
|
||
}
|
||
}
|
||
|
||
function openSettings(tab) {
|
||
if (tab) {
|
||
state.settingsTab = tab;
|
||
}
|
||
setView('settings');
|
||
}
|
||
|
||
function closeSettings() {
|
||
setView('chat');
|
||
}
|
||
|
||
function refreshPersonas() {
|
||
loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => {
|
||
if (data?.personas) {
|
||
state.personas = data.personas;
|
||
}
|
||
});
|
||
}
|
||
|
||
function prefetchCard(kind, name) {
|
||
return new Promise((resolve) => {
|
||
if (!kind || !name || typeof genericRequest !== 'function') {
|
||
resolve(null);
|
||
return;
|
||
}
|
||
const key = `${kind}:${name}`;
|
||
genericRequest(
|
||
'AssistentGetCard',
|
||
{ kind, name },
|
||
(data) => {
|
||
if (data?.card) {
|
||
state.modelCards[key] = data.card;
|
||
}
|
||
resolve(data?.card || null);
|
||
},
|
||
0,
|
||
() => resolve(null),
|
||
);
|
||
});
|
||
}
|
||
|
||
async function prefetchActiveModelCards() {
|
||
const keys = [];
|
||
const seen = new Set();
|
||
const add = (kind, name) => {
|
||
if (!kind || !name) {
|
||
return;
|
||
}
|
||
const key = `${kind}:${name}`;
|
||
if (seen.has(key)) {
|
||
return;
|
||
}
|
||
seen.add(key);
|
||
keys.push({ kind, name });
|
||
};
|
||
try {
|
||
const ck = resolveCurrentCheckpoint();
|
||
if (ck?.name) {
|
||
add('checkpoint', ck.name);
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
try {
|
||
if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
|
||
for (const l of loraHelper.selected) {
|
||
add('lora', l?.name || l);
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
for (const l of state.inventory?.loras || []) {
|
||
if (l?.has_card) {
|
||
add('lora', l.name);
|
||
}
|
||
if (keys.length >= 14) {
|
||
break;
|
||
}
|
||
}
|
||
for (const c of state.inventory?.checkpoints || []) {
|
||
if (c?.has_card) {
|
||
add('checkpoint', c.name);
|
||
}
|
||
if (keys.length >= 16) {
|
||
break;
|
||
}
|
||
}
|
||
await Promise.all(keys.map((k) => prefetchCard(k.kind, k.name)));
|
||
}
|
||
|
||
function cardsCatalog() {
|
||
const kind = $('sa_cards_kind')?.value || 'all';
|
||
const inv = state.inventory || {};
|
||
const rows = [];
|
||
if (kind === 'all' || kind === 'checkpoint') {
|
||
for (const c of inv.checkpoints || []) {
|
||
rows.push({
|
||
kind: 'checkpoint',
|
||
name: c.name,
|
||
title: c.title || c.name,
|
||
has_card: !!c.has_card,
|
||
hash: c.hash || '',
|
||
preview_url: c.preview_url || null,
|
||
has_sidecar: !!c.has_sidecar,
|
||
});
|
||
}
|
||
}
|
||
if (kind === 'all' || kind === 'lora') {
|
||
for (const l of inv.loras || []) {
|
||
rows.push({
|
||
kind: 'lora',
|
||
name: l.name,
|
||
title: l.title || l.name,
|
||
has_card: !!l.has_card,
|
||
trigger: l.trigger_phrase,
|
||
hash: l.hash || '',
|
||
preview_url: l.preview_url || null,
|
||
has_sidecar: !!l.has_sidecar,
|
||
});
|
||
}
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
function renderCardsList() {
|
||
const root = $('sa_cards_list');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
root.innerHTML = '';
|
||
const rows = cardsCatalog();
|
||
if (!rows.length) {
|
||
root.innerHTML = '<div class="sa-chat-empty-hint">Inventory пуст — Обновить.</div>';
|
||
return;
|
||
}
|
||
for (const row of rows) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'sa-card-row';
|
||
if (state.cardsSelection && state.cardsSelection.kind === row.kind && state.cardsSelection.name === row.name) {
|
||
btn.classList.add('sa-selected');
|
||
}
|
||
const thumb = row.preview_url
|
||
? `<img class="sa-card-row-thumb" src="${escapeHtml(row.preview_url)}" alt="" />`
|
||
: '<div class="sa-card-row-thumb"></div>';
|
||
const metaBits = [];
|
||
metaBits.push(row.has_card ? 'card ✓' : 'нет card');
|
||
if (row.has_sidecar) {
|
||
metaBits.push('sidecar');
|
||
}
|
||
if (isWantedModel(row)) {
|
||
metaBits.push('⏳ wanted');
|
||
btn.classList.add('sa-card-row-wanted');
|
||
}
|
||
if (row.trigger) {
|
||
metaBits.push(String(row.trigger).slice(0, 40));
|
||
}
|
||
btn.innerHTML = `${thumb}<div class="sa-card-row-body"><div class="sa-card-row-kind">${escapeHtml(row.kind)}</div><div class="sa-card-row-name">${escapeHtml(row.title || row.name)}</div><div class="sa-card-row-meta">${escapeHtml(metaBits.join(' · '))}</div></div><button type="button" class="basic-button sa-card-row-chat" data-chat="1" title="В чат">↗</button>`;
|
||
btn.addEventListener('click', (e) => {
|
||
if (e.target?.closest?.('[data-chat]')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
sendCardToChat(row);
|
||
return;
|
||
}
|
||
selectCardModel(row);
|
||
});
|
||
root.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
function sendCardToChat(row) {
|
||
if (!row?.name) {
|
||
return;
|
||
}
|
||
selectCardModel(row);
|
||
setView('chat');
|
||
const kind = row.kind === 'checkpoint' ? 'checkpoint' : 'LoRA';
|
||
const triggers = row.trigger ? ` Triggers: ${row.trigger}.` : '';
|
||
if ($('sa_input')) {
|
||
$('sa_input').value = `Используй ${kind} «${row.name}».${triggers} Учти карточку/triggers и предложи патч.`;
|
||
$('sa_input').focus();
|
||
}
|
||
setStatus(`В чат → ${row.name}`);
|
||
}
|
||
|
||
function wireCardForm() {
|
||
const sync = () => {
|
||
if ($('sa_card_show_json')?.checked) {
|
||
syncCardJsonFromForm();
|
||
}
|
||
};
|
||
['sa_card_triggers', 'sa_card_weight', 'sa_card_when', 'sa_card_avoid', 'sa_card_hint', 'sa_card_notes', 'sa_card_url']
|
||
.forEach((id) => $(id)?.addEventListener('change', sync));
|
||
$('sa_card_show_json')?.addEventListener('change', () => {
|
||
const on = !!$('sa_card_show_json')?.checked;
|
||
const ta = $('sa_card_json');
|
||
if (ta) {
|
||
ta.hidden = !on;
|
||
if (on) {
|
||
syncCardJsonFromForm();
|
||
}
|
||
}
|
||
});
|
||
$('sa_card_json')?.addEventListener('change', () => {
|
||
if ($('sa_card_show_json')?.checked) {
|
||
applyCardToForm(readCardDraft() || {});
|
||
}
|
||
});
|
||
}
|
||
|
||
function applyCardToForm(card) {
|
||
card = card || {};
|
||
const triggers = Array.isArray(card.triggers) ? card.triggers.join(', ') : (card.triggers || '');
|
||
if ($('sa_card_triggers')) {
|
||
$('sa_card_triggers').value = triggers;
|
||
}
|
||
if ($('sa_card_weight')) {
|
||
$('sa_card_weight').value = card.weight != null ? card.weight : (state.cardsSelection?.kind === 'lora' ? 0.8 : 1);
|
||
}
|
||
if ($('sa_card_when')) {
|
||
$('sa_card_when').value = card.when || '';
|
||
}
|
||
if ($('sa_card_avoid')) {
|
||
$('sa_card_avoid').value = card.avoid || '';
|
||
}
|
||
if ($('sa_card_hint')) {
|
||
$('sa_card_hint').value = card.prompt_hint || '';
|
||
}
|
||
if ($('sa_card_notes')) {
|
||
$('sa_card_notes').value = card.notes || '';
|
||
}
|
||
if ($('sa_card_url')) {
|
||
$('sa_card_url').value = card.civitai_url || '';
|
||
}
|
||
if ($('sa_card_json')) {
|
||
$('sa_card_json').value = JSON.stringify(card, null, 2);
|
||
}
|
||
}
|
||
|
||
function syncCardJsonFromForm() {
|
||
const sel = state.cardsSelection || {};
|
||
let base = {};
|
||
try {
|
||
base = JSON.parse($('sa_card_json')?.value || '{}');
|
||
} catch (e) {
|
||
base = {};
|
||
}
|
||
const triggers = String($('sa_card_triggers')?.value || '')
|
||
.split(/[,;]/)
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
const card = {
|
||
...base,
|
||
kind: sel.kind || base.kind || 'lora',
|
||
name: sel.name || base.name || '',
|
||
triggers,
|
||
weight: parseFloat($('sa_card_weight')?.value || '0.8') || 0.8,
|
||
when: $('sa_card_when')?.value || '',
|
||
avoid: $('sa_card_avoid')?.value || '',
|
||
prompt_hint: $('sa_card_hint')?.value || '',
|
||
notes: $('sa_card_notes')?.value || '',
|
||
civitai_url: $('sa_card_url')?.value || '',
|
||
version_id: base.version_id != null ? base.version_id : null,
|
||
};
|
||
if ($('sa_card_json')) {
|
||
$('sa_card_json').value = JSON.stringify(card, null, 2);
|
||
}
|
||
return card;
|
||
}
|
||
|
||
function renderCardPreviews(urls) {
|
||
const root = $('sa_card_previews');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
const list = (urls || []).filter(Boolean).slice(0, 6);
|
||
root.innerHTML = '';
|
||
if (!list.length) {
|
||
root.hidden = true;
|
||
return;
|
||
}
|
||
root.hidden = false;
|
||
for (const url of list) {
|
||
const img = document.createElement('img');
|
||
img.className = 'sa-card-thumb';
|
||
img.src = url;
|
||
img.alt = 'preview';
|
||
img.title = 'Клик — на вкладку Refs';
|
||
img.addEventListener('click', () => {
|
||
setBoardTab('refs');
|
||
addRefFromUrl(url);
|
||
setStatus('Превью → Refs');
|
||
});
|
||
root.appendChild(img);
|
||
}
|
||
}
|
||
|
||
function mergeCivitaiIntoCard(card, data) {
|
||
const out = { ...(card || {}) };
|
||
const civ = data?.civitai;
|
||
if (!out.triggers?.length && data?.trigger_phrase) {
|
||
out.triggers = [data.trigger_phrase];
|
||
}
|
||
if (civ) {
|
||
const trained = civ.trainedWords || civ.trained_words;
|
||
if ((!out.triggers || !out.triggers.length) && Array.isArray(trained) && trained.length) {
|
||
out.triggers = trained.slice(0, 12);
|
||
}
|
||
if (!out.civitai_url) {
|
||
const mid = civ.modelId || civ.model?.id || civ.model?.modelId;
|
||
const vid = civ.id || data.version_id;
|
||
if (mid && vid) {
|
||
out.civitai_url = `https://civitai.red/models/${mid}?modelVersionId=${vid}`;
|
||
} else if (vid) {
|
||
out.civitai_url = `https://civitai.red/models/0?modelVersionId=${vid}`;
|
||
}
|
||
}
|
||
if (out.version_id == null && (civ.id || data.version_id)) {
|
||
out.version_id = civ.id || data.version_id;
|
||
}
|
||
if (!out.notes && civ.description) {
|
||
out.notes = String(civ.description).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 400);
|
||
}
|
||
}
|
||
if (out.version_id == null && data?.version_id) {
|
||
out.version_id = data.version_id;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function formatMetaStatus(data) {
|
||
if (!data) {
|
||
return 'Нет ответа';
|
||
}
|
||
if (data.error) {
|
||
return String(data.error);
|
||
}
|
||
const parts = [];
|
||
if (data.has_sidecar) {
|
||
parts.push(`Сидикарь ✓ · version ${data.version_id || '?'}`);
|
||
} else if (data.fetched) {
|
||
parts.push(`Civitai ✓ · version ${data.version_id || '?'}`);
|
||
} else {
|
||
parts.push('Сидикаря нет');
|
||
}
|
||
const n = (data.example_urls || data.preview_urls || []).length;
|
||
if (n) {
|
||
parts.push(`${n} кадр${n === 1 ? '' : 'а'}`);
|
||
}
|
||
if (data.has_card) {
|
||
parts.push('карточка Assistent ✓');
|
||
} else {
|
||
parts.push('карточки Assistent нет');
|
||
}
|
||
if (data.fetch_error) {
|
||
parts.push(String(data.fetch_error));
|
||
}
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
function applyCardMetaResponse(row, data, { preserveUser } = {}) {
|
||
let card = data.card || {
|
||
kind: row.kind,
|
||
name: row.name,
|
||
triggers: data.trigger_phrase ? [data.trigger_phrase] : [],
|
||
weight: row.kind === 'lora' ? 0.8 : 1,
|
||
when: '',
|
||
avoid: '',
|
||
prompt_hint: '',
|
||
notes: '',
|
||
civitai_url: '',
|
||
version_id: data.version_id || null,
|
||
};
|
||
if (preserveUser) {
|
||
const current = syncCardJsonFromForm();
|
||
card = {
|
||
...mergeCivitaiIntoCard(card, data),
|
||
when: current.when || card.when || '',
|
||
avoid: current.avoid || card.avoid || '',
|
||
prompt_hint: current.prompt_hint || card.prompt_hint || '',
|
||
notes: current.notes || card.notes || '',
|
||
};
|
||
} else {
|
||
card = mergeCivitaiIntoCard(card, data);
|
||
}
|
||
applyCardToForm(card);
|
||
state.modelCards[`${row.kind}:${row.name}`] = card;
|
||
const urls = [
|
||
...(data.preview_urls || []),
|
||
...(data.example_urls || []),
|
||
];
|
||
renderCardPreviews(urls);
|
||
const badge = $('sa_card_badge');
|
||
if (badge) {
|
||
badge.hidden = false;
|
||
badge.textContent = data.has_card ? 'card ✓' : (data.has_sidecar || data.fetched ? 'meta ✓' : 'нет меты');
|
||
}
|
||
setCardStatus(formatMetaStatus(data));
|
||
}
|
||
|
||
function selectCardModel(row) {
|
||
state.cardsSelection = row;
|
||
renderCardsList();
|
||
if ($('sa_card_title')) {
|
||
$('sa_card_title').textContent = row.title || row.name;
|
||
}
|
||
const badge = $('sa_card_badge');
|
||
if (badge) {
|
||
badge.hidden = false;
|
||
badge.textContent = '…';
|
||
}
|
||
setCardStatus('Читаю локальную мету…');
|
||
genericRequest(
|
||
'AssistentGetCardMeta',
|
||
{ kind: row.kind, name: row.name, fetch: false },
|
||
(data) => applyCardMetaResponse(row, data || {}),
|
||
0,
|
||
(err) => setCardStatus(String(err || 'Ошибка загрузки')),
|
||
);
|
||
}
|
||
|
||
function fetchCardMetaLive() {
|
||
const row = state.cardsSelection;
|
||
if (!row) {
|
||
setCardStatus('Выбери модель');
|
||
return;
|
||
}
|
||
setCardStatus('Сидикаря нет · ищу по SHA…');
|
||
genericRequest(
|
||
'AssistentGetCardMeta',
|
||
{ kind: row.kind, name: row.name, fetch: true },
|
||
(data) => applyCardMetaResponse(row, data || {}, { preserveUser: true }),
|
||
0,
|
||
(err) => setCardStatus(String(err || 'Civitai: ошибка запроса')),
|
||
);
|
||
}
|
||
|
||
async function addRefFromUrl(url) {
|
||
if (!url) {
|
||
return;
|
||
}
|
||
addRefSlot({ src: url, select: false });
|
||
}
|
||
|
||
function readCardDraft() {
|
||
const fromForm = syncCardJsonFromForm();
|
||
if ($('sa_card_show_json')?.checked) {
|
||
const raw = $('sa_card_json')?.value || '';
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch (e) {
|
||
setCardStatus('Невалидный JSON');
|
||
return null;
|
||
}
|
||
}
|
||
return fromForm;
|
||
}
|
||
|
||
function saveCurrentCard({ enqueue } = {}) {
|
||
const sel = state.cardsSelection;
|
||
if (!sel) {
|
||
setCardStatus('Выбери модель');
|
||
return;
|
||
}
|
||
const card = readCardDraft();
|
||
if (!card) {
|
||
return;
|
||
}
|
||
card.kind = card.kind || sel.kind;
|
||
card.name = card.name || sel.name;
|
||
setCardStatus('Сохраняю…');
|
||
genericRequest(
|
||
'AssistentSaveCard',
|
||
{ kind: sel.kind, name: sel.name, card, enqueue_wanted: !!enqueue },
|
||
(data) => {
|
||
if (data.error) {
|
||
setCardStatus(data.error);
|
||
return;
|
||
}
|
||
state.modelCards[`${sel.kind}:${sel.name}`] = card;
|
||
setCardStatus(data.installed
|
||
? `Карточка Assistent сохранена · ${data.path}`
|
||
: `Черновик + wanted · ${data.path}`);
|
||
refreshInventory(() => renderCardsList());
|
||
if (enqueue || !data.installed) {
|
||
refreshWantedQueue();
|
||
}
|
||
},
|
||
0,
|
||
(err) => setCardStatus(String(err || 'Ошибка сохранения')),
|
||
);
|
||
}
|
||
|
||
function enqueueWantedOnly() {
|
||
const sel = state.cardsSelection;
|
||
const card = readCardDraft() || {};
|
||
if (!sel && !card.civitai_url) {
|
||
setCardStatus('Нужна модель или civitai_url');
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentEnqueueWanted',
|
||
{
|
||
kind: (card.kind || sel?.kind || 'lora'),
|
||
url: card.civitai_url || '',
|
||
version_id: card.version_id || 0,
|
||
title: card.name || sel?.name || '',
|
||
card,
|
||
},
|
||
(data) => {
|
||
setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
|
||
refreshWantedQueue();
|
||
},
|
||
0,
|
||
(err) => setCardStatus(String(err || 'Ошибка enqueue')),
|
||
);
|
||
}
|
||
|
||
function shortLoraName(name) {
|
||
const s = String(name || '');
|
||
const base = s.split(/[/\\]/).pop() || s;
|
||
return base.replace(/\.safetensors$/i, '').slice(0, 28);
|
||
}
|
||
|
||
function renderLoraChips() {
|
||
const root = $('sa_lora_chips');
|
||
if (!root) {
|
||
return;
|
||
}
|
||
root.innerHTML = '';
|
||
let selected = [];
|
||
try {
|
||
if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
|
||
selected = loraHelper.selected.map((l) => ({
|
||
name: l.name || l,
|
||
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
|
||
}));
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
for (const l of selected) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'sa-lora-chip';
|
||
btn.title = `${l.name} ×${l.weight} — клик снять`;
|
||
btn.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
|
||
btn.addEventListener('click', () => {
|
||
try {
|
||
if (typeof loraHelper !== 'undefined' && typeof loraHelper.removeLora === 'function') {
|
||
loraHelper.removeLora(l.name);
|
||
} else if (loraHelper?.selected) {
|
||
loraHelper.selected = loraHelper.selected.filter((x) => (x.name || x) !== l.name);
|
||
if (typeof loraHelper.rebuildUI === 'function') {
|
||
loraHelper.rebuildUI();
|
||
}
|
||
}
|
||
} catch (e) { /* ignore */ }
|
||
renderLoraChips();
|
||
});
|
||
root.appendChild(btn);
|
||
}
|
||
const add = document.createElement('button');
|
||
add.type = 'button';
|
||
add.className = 'sa-lora-chip sa-lora-add';
|
||
add.textContent = '+ LoRA';
|
||
add.title = 'Добавить из inventory';
|
||
add.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
openLoraPicker(add);
|
||
});
|
||
root.appendChild(add);
|
||
}
|
||
|
||
function openLoraPicker(anchor) {
|
||
document.querySelectorAll('.sa-lora-picker').forEach((n) => n.remove());
|
||
const picker = document.createElement('div');
|
||
picker.className = 'sa-lora-picker';
|
||
const inv = (state.inventory?.loras || []).slice().sort((a, b) => (b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0));
|
||
const filter = document.createElement('input');
|
||
filter.type = 'search';
|
||
filter.placeholder = 'Фильтр LoRA…';
|
||
filter.style.cssText = 'width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem;';
|
||
picker.appendChild(filter);
|
||
const list = document.createElement('div');
|
||
picker.appendChild(list);
|
||
const draw = () => {
|
||
list.innerHTML = '';
|
||
const q = filter.value.trim().toLowerCase();
|
||
let n = 0;
|
||
for (const l of inv) {
|
||
const name = l.name || '';
|
||
if (q && !String(name).toLowerCase().includes(q) && !String(l.title || '').toLowerCase().includes(q)) {
|
||
continue;
|
||
}
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.textContent = `${shortLoraName(name)}${l.krea_likely ? ' · krea' : ''}`;
|
||
btn.title = name;
|
||
btn.addEventListener('click', async () => {
|
||
await applyPatch({
|
||
loras: [
|
||
...((() => {
|
||
try {
|
||
return (loraHelper?.selected || []).map((x) => ({
|
||
name: x.name || x,
|
||
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[x.name || x]) || 1,
|
||
}));
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
})()),
|
||
{ name, weight: l.default_weight ? parseFloat(l.default_weight) : 0.8, triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []) },
|
||
],
|
||
}, 'loras');
|
||
picker.remove();
|
||
renderLoraChips();
|
||
});
|
||
list.appendChild(btn);
|
||
if (++n >= 40) {
|
||
break;
|
||
}
|
||
}
|
||
if (!n) {
|
||
list.innerHTML = '<div class="sa-chat-empty-hint">Нет LoRA</div>';
|
||
}
|
||
};
|
||
filter.addEventListener('input', draw);
|
||
draw();
|
||
const composer = $('sa_composer') || document.body;
|
||
composer.style.position = composer.style.position || 'relative';
|
||
composer.appendChild(picker);
|
||
const onDoc = (ev) => {
|
||
if (!picker.contains(ev.target) && ev.target !== anchor) {
|
||
picker.remove();
|
||
document.removeEventListener('mousedown', onDoc);
|
||
}
|
||
};
|
||
setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
|
||
filter.focus();
|
||
}
|
||
|
||
function loadTasteFromServer() {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentGetTaste',
|
||
{},
|
||
(data) => {
|
||
const remote = data?.taste;
|
||
if (!remote || typeof remote !== 'object') {
|
||
return;
|
||
}
|
||
const remoteUpdated = remote.updated || 0;
|
||
const localUpdated = state.taste?.updated || 0;
|
||
// sqlite taste is the source of truth; localStorage only wins when it is strictly newer.
|
||
const localEmpty = !localUpdated
|
||
&& !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
|
||
if (localEmpty || remoteUpdated >= localUpdated) {
|
||
state.taste = {
|
||
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
|
||
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
|
||
avoid: Array.isArray(remote.avoid) ? remote.avoid.slice(0, 12) : [],
|
||
notes: String(remote.notes || '').slice(0, 400),
|
||
updated: remoteUpdated || Date.now(),
|
||
};
|
||
saveTasteLocalOnly();
|
||
}
|
||
},
|
||
0,
|
||
() => {},
|
||
);
|
||
}
|
||
|
||
function saveTasteLocalOnly() {
|
||
try {
|
||
localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function saveTasteToServerDebounced() {
|
||
if (state.tasteSaveTimer) {
|
||
clearTimeout(state.tasteSaveTimer);
|
||
}
|
||
state.tasteSaveTimer = setTimeout(() => {
|
||
if (typeof genericRequest !== 'function') {
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'AssistentSaveTaste',
|
||
{ taste: state.taste || {} },
|
||
() => {},
|
||
0,
|
||
() => {},
|
||
);
|
||
}, 800);
|
||
}
|
||
|
||
async function generateCardWithAssistent() {
|
||
const sel = state.cardsSelection;
|
||
if (!sel) {
|
||
setCardStatus('Выбери модель');
|
||
return;
|
||
}
|
||
if (state.busy) {
|
||
setCardStatus('Чат занят');
|
||
return;
|
||
}
|
||
setPackValue('catalog_card', { flash: true });
|
||
setView('chat');
|
||
const meta = await new Promise((resolve) => {
|
||
genericRequest(
|
||
'AssistentGetCardMeta',
|
||
{ kind: sel.kind, name: sel.name },
|
||
(data) => resolve(data),
|
||
0,
|
||
() => resolve(null),
|
||
);
|
||
});
|
||
const forced = `Write a recommendation card for this ${sel.kind}: ${sel.name}. Use metadata/triggers only; output one JSON card.`;
|
||
await sendChat({
|
||
forcedUserText: forced,
|
||
skipSlash: true,
|
||
skipAutoPack: true,
|
||
fromCards: true,
|
||
cardTarget: {
|
||
kind: sel.kind,
|
||
name: sel.name,
|
||
meta,
|
||
},
|
||
});
|
||
}
|
||
|
||
function inventoryIsStale(maxAgeMs = 20000) {
|
||
if (!state.inventoryFetchedAt) {
|
||
return true;
|
||
}
|
||
return (Date.now() - state.inventoryFetchedAt) > maxAgeMs;
|
||
}
|
||
|
||
async function ensureFreshInventory({ forceRescan } = {}) {
|
||
const rescan = forceRescan || inventoryIsStale(20000);
|
||
await refreshInventoryAsync({ rescan });
|
||
}
|
||
|
||
function triggerSwarmModelRefresh(done) {
|
||
if (typeof genericRequest !== 'function') {
|
||
if (done) {
|
||
done();
|
||
}
|
||
return;
|
||
}
|
||
genericRequest(
|
||
'TriggerRefresh',
|
||
{ strong: true },
|
||
() => {
|
||
if (done) {
|
||
done();
|
||
}
|
||
},
|
||
0,
|
||
() => {
|
||
if (done) {
|
||
done();
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
async function handleReplySideEffects(reply, civitaiResults, opts = {}) {
|
||
const { fromAutoCritique, fromVisionHop, fromCards, fromDebug } = opts;
|
||
if (fromCards) {
|
||
const card = extractCardJson(reply);
|
||
if (card) {
|
||
if ($('sa_card_json')) {
|
||
$('sa_card_json').value = JSON.stringify(card, null, 2);
|
||
}
|
||
if (opts.fromDownload || opts.cardTarget) {
|
||
const kind = card.kind || opts.cardTarget?.kind || 'lora';
|
||
const name = card.name || opts.cardTarget?.name;
|
||
if (name && typeof genericRequest === 'function') {
|
||
genericRequest(
|
||
'AssistentSaveCard',
|
||
{ kind, name, card, enqueue_wanted: false },
|
||
(data) => {
|
||
if (data?.path) {
|
||
state.modelCards[`${kind}:${name}`] = card;
|
||
setCardStatus(data.installed ? `Card saved → ${data.path}` : `Card draft → ${data.path}`);
|
||
setStatus(`Card saved for ${name}`);
|
||
}
|
||
},
|
||
0,
|
||
() => setCardStatus('Card draft ready — Save manually'),
|
||
);
|
||
}
|
||
} else {
|
||
setView('cards');
|
||
setCardStatus('Draft from Assistent — review & Save');
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
const { patch } = extractPatch(reply);
|
||
let effective = patch;
|
||
if (!effective && !fromVisionHop && !fromAutoCritique && !fromDebug) {
|
||
const aspect = parseAspectFromUserText(opts.userText || '');
|
||
if (aspect && (replyMissingJsonPatch(reply) || isSameButAspectRequest(opts.userText || ''))) {
|
||
effective = { aspect, actions: ['generate'] };
|
||
if (state.lastPatch?.prompt) {
|
||
effective.prompt = state.lastPatch.prompt;
|
||
}
|
||
appendSystemNote(`Патч пустой — применил aspect ${aspect} сам.`);
|
||
}
|
||
}
|
||
if (effective) {
|
||
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);
|
||
}
|
||
}
|
||
if (effective && !fromVisionHop && !fromAutoCritique) {
|
||
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 = !!(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 || '');
|
||
if (!fromAutoCritique && (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(wantsGen ? 'Применено' : '');
|
||
}
|
||
} else if (replyMissingJsonPatch(reply) && !fromAutoCritique && !fromVisionHop) {
|
||
setStatus('Ответ без JSON-патча — ничего не применено');
|
||
}
|
||
state.pendingSilentGen = false;
|
||
}
|
||
|
||
async function applyQuickPatch(patch, note) {
|
||
const withActions = { ...patch };
|
||
if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) {
|
||
withActions.actions = ['generate'];
|
||
}
|
||
const prevIntent = state.lastUserParamIntent;
|
||
state.lastUserParamIntent = true;
|
||
await applyPatch(withActions, 'all');
|
||
state.lastUserParamIntent = prevIntent;
|
||
setStatus(note || 'Applied');
|
||
if ($('sa_auto_generate')?.checked) {
|
||
await runGenerateFromPatch(withActions);
|
||
}
|
||
syncChipHighlight();
|
||
}
|
||
|
||
function syncChipHighlight() {
|
||
const bar = $('sa_chips');
|
||
if (!bar) {
|
||
return;
|
||
}
|
||
const cur = guessAspectFromSize(val('input_width'), val('input_height'));
|
||
const seed = val('input_seed');
|
||
bar.querySelectorAll('[data-aspect]').forEach((btn) => {
|
||
btn.classList.toggle('sa-chip-active', btn.getAttribute('data-aspect') === cur);
|
||
});
|
||
bar.querySelectorAll('[data-seed]').forEach((btn) => {
|
||
const mode = btn.getAttribute('data-seed');
|
||
const active = (mode === 'lock' && seed && seed !== '-1') || (mode === 'random' && (!seed || seed === '-1'));
|
||
btn.classList.toggle('sa-chip-active', active);
|
||
});
|
||
}
|
||
|
||
function appendSystemNote(text) {
|
||
const box = $('sa_messages');
|
||
if (!box) {
|
||
return;
|
||
}
|
||
hideChatEmpty();
|
||
const div = document.createElement('div');
|
||
div.className = 'sa-msg assistant sa-system-note';
|
||
div.textContent = text;
|
||
box.appendChild(div);
|
||
scrollMessagesToBottom();
|
||
}
|
||
|
||
function clipDebug(s, max) {
|
||
const t = String(s || '').replace(/\s+/g, ' ').trim();
|
||
if (!t) {
|
||
return '—';
|
||
}
|
||
return t.length > max ? `${t.slice(0, max)}…` : t;
|
||
}
|
||
|
||
function formatDebugLoras(list) {
|
||
if (!Array.isArray(list) || !list.length) {
|
||
return 'нет';
|
||
}
|
||
return list.slice(0, 8).map((l) => {
|
||
const name = l?.name || l;
|
||
const w = l?.weight != null ? `@${l.weight}` : '';
|
||
return `${name}${w}`;
|
||
}).join(', ');
|
||
}
|
||
|
||
function buildDebugSummary() {
|
||
const persona = $('sa_persona')?.value || 'neutral';
|
||
const pack = $('sa_pack')?.value || defaultPackId();
|
||
const chatModel = $('sa_model')?.value || '—';
|
||
const embed = $('sa_embed_model')?.value || state.preferredEmbed || '—';
|
||
const profile = detectKreaProfileName();
|
||
const defaults = mergedGenerationDefaults(profile);
|
||
const session = state.sessionExact || {};
|
||
const exactGen = state.exact?.generation || state.config?.exact?.generation || {};
|
||
const ctx = (() => {
|
||
try {
|
||
return collectLiveContext();
|
||
} catch (e) {
|
||
return {};
|
||
}
|
||
})();
|
||
const aspect = guessAspectFromSize(ctx.width, ctx.height) || defaults.aspect || '—';
|
||
const why = [];
|
||
if (Object.keys(session).length) {
|
||
why.push(`session_exact перекрывает Exact: ${Object.keys(session).join(', ')}`);
|
||
} else {
|
||
why.push('session_exact пуст — params из Exact + профиль чекпоинта');
|
||
}
|
||
why.push(`профиль чекпоинта: ${profile} (имя/title → turbo|raw)`);
|
||
if (persona === 'cinema' || state.exact?.generation?.aspect) {
|
||
why.push(`persona/exact aspect: ${state.exact?.generation?.aspect || exactGen.aspect || '—'}`);
|
||
}
|
||
if (state.lastPatch) {
|
||
const keys = Object.keys(state.lastPatch).filter((k) => state.lastPatch[k] != null && k !== 'notes');
|
||
why.push(`последний патч задал: ${keys.slice(0, 12).join(', ')}`);
|
||
} else {
|
||
why.push('последнего патча Assistent ещё нет');
|
||
}
|
||
why.push('приоритет: user → About the user → session_exact → exact(+persona) → live UI → craft memory_hits');
|
||
|
||
const lines = [
|
||
'### Debug Assistent',
|
||
`persona=${persona} · pack=${pack}`,
|
||
`chat=${chatModel} · embed=${embed}`,
|
||
`skills=${(state.enabledSkills || []).join(',') || '—'}`,
|
||
`auto: apply=${!!$('sa_auto_apply')?.checked} gen=${!!$('sa_auto_generate')?.checked} vision=${!!$('sa_auto_vision')?.checked} critique=${!!$('sa_auto_critique')?.checked}`,
|
||
'',
|
||
'Live SwarmUI:',
|
||
` ckpt=${ctx.checkpoint?.name || '—'} · krea_profile=${ctx.krea_profile || profile}`,
|
||
` ${ctx.width || '?'}×${ctx.height || '?'} (${aspect}) · steps=${ctx.steps ?? '—'} · cfg=${ctx.cfg ?? '—'} · sigma=${ctx.sigma_shift ?? '—'} · seed=${ctx.seed ?? '—'} · batch=${ctx.batch ?? '—'}`,
|
||
` loras: ${formatDebugLoras(ctx.selected_loras || ctx.enabled_loras)}`,
|
||
` available_loras=${(ctx.available_loras || []).length}${ctx.available_loras_truncated ? ` truncated/${ctx.available_loras_total || '?'}` : ''}`,
|
||
` prompt: ${clipDebug(ctx.prompt, 220)}`,
|
||
` negative: ${clipDebug(ctx.negative, 120)}`,
|
||
` init=${!!ctx.has_init_image} mask=${!!ctx.has_mask_image} prompt_images=${ctx.prompt_image_count || 0}`,
|
||
` has_vision_image=${!!ctx.has_vision_image} · images_in_request=${!!ctx.images_in_request} · vision_ready=${visionReadySlots().length}`,
|
||
` context_json_chars≈${JSON.stringify(ctx).length} · last_system_chars=${state.lastSystemChars || '—'} · last_context_chars=${state.lastContextChars || '—'}`,
|
||
state.lastSystemLayers
|
||
? ` system_layers: ${Object.entries(state.lastSystemLayers).map(([k, v]) => `${k}=${v}`).join(' · ')}`
|
||
: ' system_layers: — (отправь сообщение, чтобы заполнить)',
|
||
'',
|
||
'Exact defaults (merged):',
|
||
` generation=${JSON.stringify(exactGen)}`,
|
||
` effective=${JSON.stringify({
|
||
steps: defaults.steps,
|
||
cfg: defaults.cfg,
|
||
sigma_shift: defaults.sigma_shift,
|
||
aspect: defaults.aspect,
|
||
images: defaults.images,
|
||
profile: defaults.profile,
|
||
})}`,
|
||
` session_exact=${Object.keys(session).length ? JSON.stringify(session) : '{}'}`,
|
||
'',
|
||
'Почему так:',
|
||
...why.map((w) => ` · ${w}`),
|
||
];
|
||
if (state.lastPatch) {
|
||
lines.push('', `last_patch: ${clipDebug(JSON.stringify(state.lastPatch), 360)}`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
async function handleSlashCommand(raw) {
|
||
const text = String(raw || '').trim();
|
||
if (!text.startsWith('/')) {
|
||
return false;
|
||
}
|
||
const parts = text.slice(1).split(/\s+/);
|
||
const cmd = (parts[0] || '').toLowerCase();
|
||
const arg = parts.slice(1).join(' ').trim();
|
||
|
||
if (cmd === 'help' || cmd === '?') {
|
||
appendSystemNote(HELP_TEXT);
|
||
setStatus('/help');
|
||
return true;
|
||
}
|
||
if (cmd === 'new' || cmd === 'newchat') {
|
||
await startNewChat({ saveCurrent: true });
|
||
return true;
|
||
}
|
||
if (cmd === 'history' || cmd === 'chats' || cmd === 'sessions') {
|
||
setChatsPanelOpen(true);
|
||
setStatus('/history');
|
||
return true;
|
||
}
|
||
if (cmd === 'debug' || cmd === 'dbg' || cmd === 'why') {
|
||
const dump = buildDebugSummary();
|
||
appendSystemNote(dump);
|
||
const argL = String(arg || '').toLowerCase().trim();
|
||
const wantLlm = cmd === 'why'
|
||
|| /^(ask|llm|explain|поясни|почему|модель)(\s|$)/i.test(argL);
|
||
if (wantLlm) {
|
||
setStatus('/debug ask…');
|
||
await sendChat({
|
||
forcedUserText:
|
||
'Отладка Assistent. Ниже факты UI (уже собраны клиентом). ' +
|
||
'Кратко своими словами (5–10 строк, язык пользователя): какие промпт/params сейчас, ' +
|
||
'что из Exact vs session_exact vs live, что сделал последний патч и почему так логично. ' +
|
||
'Без JSON patch, без generate, без look_at.\n\n' + dump,
|
||
skipSlash: true,
|
||
skipAutoPack: true,
|
||
fromDebug: true,
|
||
});
|
||
} else {
|
||
setStatus('/debug');
|
||
}
|
||
return true;
|
||
}
|
||
if (cmd === 'gen' || cmd === 'generate') {
|
||
const prev = findCurrentGenerateSrc();
|
||
startBusyUi('generating');
|
||
state.generating = true;
|
||
setInterruptVisible(true);
|
||
if (!triggerGenerate()) {
|
||
state.generating = false;
|
||
stopBusyUi('Could not start Generate');
|
||
return true;
|
||
}
|
||
const src = await waitForNewImage(prev);
|
||
state.generating = false;
|
||
setInterruptVisible(state.busy);
|
||
if (src) {
|
||
const gen = generateSlot();
|
||
if (gen) {
|
||
gen.src = src;
|
||
renderBoard();
|
||
}
|
||
stopBusyUi('Generate done');
|
||
} else {
|
||
stopBusyUi('Generate finished');
|
||
}
|
||
return true;
|
||
}
|
||
if (cmd === 'look') {
|
||
const id = normalizeSlotId(arg || GEN_ID) || GEN_ID;
|
||
const slot = slotById(id);
|
||
if (!slot) {
|
||
setStatus(`Неизвестный слот: ${arg || GEN_ID}`);
|
||
return true;
|
||
}
|
||
if (slot.type !== 'generate') {
|
||
setBoardTab('refs');
|
||
} else {
|
||
setBoardTab('generate');
|
||
}
|
||
if (!slot.src && id === GEN_ID) {
|
||
const src = findCurrentGenerateSrc();
|
||
if (src) {
|
||
slot.src = src;
|
||
}
|
||
}
|
||
if (!slot.src) {
|
||
setStatus(`Slot ${id} is empty`);
|
||
return true;
|
||
}
|
||
slot.attach = true;
|
||
renderBoard();
|
||
if ($('sa_input')) {
|
||
$('sa_input').value = `Look at ${id} and describe what you see.`;
|
||
}
|
||
setPackValue('critique_image', { flash: true });
|
||
await sendChat({ forceSlotIds: [id], skipAutoPack: true });
|
||
return true;
|
||
}
|
||
if (cmd === 'init') {
|
||
const src = selectedSrc() || findCurrentGenerateSrc();
|
||
if (!src) {
|
||
setStatus('No image for Init');
|
||
return true;
|
||
}
|
||
await setInitFromSrc(src);
|
||
setPackValue('inpaint_edit', { flash: true });
|
||
return true;
|
||
}
|
||
if (cmd === 'mask') {
|
||
const src = selectedSrc();
|
||
if (!src) {
|
||
setStatus('Select a window with a mask image');
|
||
return true;
|
||
}
|
||
await setMaskFromSrc(src);
|
||
setPackValue('inpaint_edit', { flash: true });
|
||
return true;
|
||
}
|
||
if (cmd === 'clear') {
|
||
clearInitAndMask();
|
||
return true;
|
||
}
|
||
if (cmd === 'interrupt' || cmd === 'stop') {
|
||
doInterruptNow();
|
||
clearInFlightUi({ status: 'Прервано' });
|
||
return true;
|
||
}
|
||
if (cmd === 'aspect') {
|
||
const key = normalizeAspect(arg);
|
||
if (!key) {
|
||
setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`);
|
||
return true;
|
||
}
|
||
await applyQuickPatch({ aspect: key, actions: ['generate'] }, `Aspect ${key}`);
|
||
return true;
|
||
}
|
||
if (cmd === 'seed') {
|
||
const mode = (arg || 'random').toLowerCase();
|
||
if (mode === 'lock' || mode === 'keep') {
|
||
await applyQuickPatch({ lock_seed: true }, 'Seed locked');
|
||
} else {
|
||
await applyQuickPatch({ seed: -1, vary: true, actions: ['generate'] }, 'Seed random');
|
||
}
|
||
return true;
|
||
}
|
||
if (cmd === 'vary') {
|
||
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)');
|
||
return true;
|
||
}
|
||
if (cmd === 'inventory' || cmd === 'inv') {
|
||
setStatus('Rescanning models…');
|
||
triggerSwarmModelRefresh(async () => {
|
||
await refreshInventoryAsync({ rescan: true });
|
||
const n = state.inventory?.loras?.length || 0;
|
||
const ck = state.inventory?.checkpoints?.length || 0;
|
||
appendSystemNote(`Inventory refreshed: ${n} LoRAs, ${ck} checkpoints.`);
|
||
setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts (rescanned)`);
|
||
});
|
||
return true;
|
||
}
|
||
if (cmd === 'pack') {
|
||
if (!setPackValue(arg, { flash: true, user: true })) {
|
||
setStatus('Pack: write|critique|compose|params|inpaint|describe');
|
||
} else {
|
||
setStatus(`Pack → ${$('sa_pack')?.value}`);
|
||
}
|
||
return true;
|
||
}
|
||
if (cmd === 'остынь' || cmd === 'ostyn' || cmd === 'cool' || cmd === 'cooldown') {
|
||
coolDownHorny();
|
||
return true;
|
||
}
|
||
if (cmd === 'horny-game' || cmd === 'hornygame' || cmd === 'horny_game') {
|
||
await startHornyGame();
|
||
return true;
|
||
}
|
||
if (cmd === 'civitai') {
|
||
if (!arg) {
|
||
setStatus('/civitai <query>');
|
||
return true;
|
||
}
|
||
if ($('sa_input')) {
|
||
$('sa_input').value = `Find a Krea 2 LoRA for: ${arg}`;
|
||
}
|
||
setPackValue(defaultPackId(), { flash: true });
|
||
await sendChat({
|
||
skipAutoPack: true,
|
||
forcedUserText: `Search Civitai for Krea-compatible LoRA: ${arg}. Prefer actions search_civitai.`,
|
||
});
|
||
return true;
|
||
}
|
||
if (cmd === 'persona') {
|
||
const sub = (parts[1] || 'new').toLowerCase();
|
||
const rest = parts.slice(2).join(' ').trim();
|
||
setPackValue('author_persona', { flash: true, user: true });
|
||
if (sub === 'save') {
|
||
await sendChat({
|
||
skipAutoPack: true,
|
||
forcedUserText:
|
||
'Сохрани согласованный черновик личности сейчас (persona_clone / persona_write). Не удаляй личности.',
|
||
});
|
||
return true;
|
||
}
|
||
const fromId = sub === 'clone' && rest
|
||
? rest.split(/\s+/)[0]
|
||
: ($('sa_persona')?.value || 'neutral');
|
||
await sendChat({
|
||
skipAutoPack: true,
|
||
forcedUserText:
|
||
`Начни интервью author_persona: клон с источника «${fromId}». ` +
|
||
'Спрашивай по полкам группами. Не пиши на диск, пока мало ответов. Не удаляй личности.',
|
||
});
|
||
return true;
|
||
}
|
||
|
||
appendSystemNote(`Unknown command /${cmd}.\n\n${HELP_TEXT}`);
|
||
setStatus(`Unknown /${cmd}`);
|
||
return true;
|
||
}
|
||
|
||
async function maybeVisionHop(patch, attachedSlotIds) {
|
||
const ids = lookAtIdsFromPatch(patch);
|
||
if (!ids.length || state.visionHopUsed) {
|
||
return false;
|
||
}
|
||
scrubPreviewFromGenerateSlot();
|
||
const have = ids.map((id) => slotById(id)).filter((s) => s && s.src && !looksLikeModelPreview(s.src));
|
||
if (!have.length) {
|
||
const genSrc = findCurrentGenerateSrc();
|
||
if (ids.includes(GEN_ID) && genSrc) {
|
||
const gen = generateSlot();
|
||
if (gen) {
|
||
gen.src = genSrc;
|
||
have.push(gen);
|
||
}
|
||
}
|
||
}
|
||
if (!have.length) {
|
||
setStatus('look_at: нет реального кадра (превью модели пропущено)');
|
||
return false;
|
||
}
|
||
const already = new Set(attachedSlotIds || []);
|
||
const need = have.filter((s) => !already.has(s.id));
|
||
if (!need.length) {
|
||
return false;
|
||
}
|
||
state.visionHopUsed = true;
|
||
for (const s of need) {
|
||
s.attach = true;
|
||
}
|
||
renderBoard();
|
||
if ($('sa_input')) {
|
||
$('sa_input').value = `Look at board slots: ${need.map((s) => s.id).join(', ')}. Continue using these images.`;
|
||
}
|
||
setStatus(`Vision hop ← ${need.map((s) => s.label).join(', ')}`);
|
||
await sendChat({ fromVisionHop: true, forceSlotIds: need.map((s) => s.id) });
|
||
return true;
|
||
}
|
||
|
||
async function sendChat(opts = {}) {
|
||
if ((state.busy || state.generating) && !opts.fromVisionHop && !opts.fromAutoCritique) {
|
||
return;
|
||
}
|
||
const rawInput = ($('sa_input')?.value || '').trim();
|
||
const text = (opts.forcedUserText || rawInput).trim();
|
||
if (!text) {
|
||
return;
|
||
}
|
||
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
|
||
state.lastUserParamIntent = userTextMentionsParams(text);
|
||
state.lastUserControlIntent = userTextMentionsControls(text);
|
||
state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text);
|
||
}
|
||
|
||
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.skipSlash) {
|
||
if (rawInput.startsWith('/')) {
|
||
if ($('sa_input')) {
|
||
$('sa_input').value = '';
|
||
}
|
||
const handled = await handleSlashCommand(rawInput);
|
||
if (handled) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
// «такую же, только 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 = '<span class="sa-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="sa-typing-label">Уточняю…</span>';
|
||
}
|
||
setBusyPhase('refining');
|
||
return;
|
||
}
|
||
if (data.delta) {
|
||
appendStreamDelta(data.delta);
|
||
return;
|
||
}
|
||
if (data.done || data.reply != null) {
|
||
const reply = data.reply || (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || '';
|
||
const civitai = data.civitai_results || [];
|
||
finalizeStreamMessage(reply, civitai);
|
||
finishOk(reply, civitai, { system_chars: data.system_chars, system_layers: data.system_layers });
|
||
}
|
||
},
|
||
0,
|
||
(err) => {
|
||
if (chatEpoch !== state.chatEpoch) {
|
||
return;
|
||
}
|
||
// Fallback to HTTP AssistentChat
|
||
console.warn('AssistentChatWS failed, falling back', err);
|
||
if (state.streamEl) {
|
||
state.streamEl.remove();
|
||
state.streamEl = null;
|
||
state.streamMeta = null;
|
||
}
|
||
genericRequest(
|
||
'AssistentChat',
|
||
payload,
|
||
(data) => {
|
||
if (chatEpoch !== state.chatEpoch) {
|
||
return;
|
||
}
|
||
if (data.error) {
|
||
finishErr(String(data.error));
|
||
return;
|
||
}
|
||
const reply = data.reply || '';
|
||
appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
|
||
finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
|
||
},
|
||
0,
|
||
(err2) => finishErr(String(err2 || err || 'Chat failed')),
|
||
);
|
||
},
|
||
);
|
||
return;
|
||
}
|
||
|
||
genericRequest(
|
||
'AssistentChat',
|
||
payload,
|
||
(data) => {
|
||
if (chatEpoch !== state.chatEpoch) {
|
||
return;
|
||
}
|
||
if (data.error) {
|
||
finishErr(String(data.error));
|
||
return;
|
||
}
|
||
const reply = data.reply || '';
|
||
appendMessage('assistant', reply, null, data.civitai_results || [], msgMeta);
|
||
finishOk(reply, data.civitai_results || [], { system_chars: data.system_chars, system_layers: data.system_layers });
|
||
},
|
||
0,
|
||
(err) => finishErr(String(err || 'Chat failed')),
|
||
);
|
||
}
|
||
|
||
function wireDropZone() {
|
||
const board = $('sa_board');
|
||
const layout = $('sa_layout');
|
||
layout?.addEventListener('dragover', (e) => {
|
||
if (e.dataTransfer?.types?.includes('Files') || e.dataTransfer?.types?.includes('text/uri-list')) {
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
layout?.addEventListener('drop', async (e) => {
|
||
if (!e.dataTransfer) {
|
||
return;
|
||
}
|
||
if (e.target && e.target.closest && e.target.closest('.sa-slot, .sa-add-cell')) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
await handleDropDataTransfer(e.dataTransfer);
|
||
});
|
||
board?.addEventListener('keydown', (e) => {
|
||
if (e.key === '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();
|
||
}
|
||
})();
|