diff --git a/Assets/assistent.css b/Assets/assistent.css
index 591ae30..da0c435 100644
--- a/Assets/assistent.css
+++ b/Assets/assistent.css
@@ -34,9 +34,10 @@
.sa-board-head {
display: flex;
- align-items: baseline;
+ align-items: center;
gap: 0.45rem;
min-height: 1.6rem;
+ flex-wrap: wrap;
}
.sa-board-title {
@@ -46,6 +47,43 @@
font-size: 0.78rem;
}
+.sa-board-tabs {
+ display: flex;
+ gap: 0.2rem;
+ flex: 1;
+ min-width: 0;
+}
+
+.sa-board-tab {
+ appearance: none;
+ border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
+ background: transparent;
+ color: inherit;
+ border-radius: 999px;
+ padding: 0.15rem 0.55rem;
+ font-size: 0.75rem;
+ cursor: pointer;
+ opacity: 0.7;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.sa-board-tab.sa-board-tab-active {
+ opacity: 1;
+ background: color-mix(in srgb, currentColor 12%, transparent);
+ border-color: color-mix(in srgb, currentColor 40%, transparent);
+ font-weight: 600;
+}
+
+.sa-board-badge {
+ font-size: 0.65rem;
+ padding: 0.05rem 0.35rem;
+ border-radius: 999px;
+ background: color-mix(in srgb, currentColor 18%, transparent);
+ opacity: 0.9;
+}
+
.sa-board-hint {
flex: 1;
font-size: 0.75rem;
@@ -66,6 +104,17 @@
outline: none;
}
+.sa-board.sa-board-gen-only {
+ grid-template-columns: 1fr;
+ grid-auto-rows: 1fr;
+}
+
+.sa-board.sa-board-gen-only .sa-slot.sa-slot-gen {
+ grid-column: auto;
+ min-height: 100%;
+ height: 100%;
+}
+
.sa-splitter {
flex: 0 0 0.45rem;
margin: 0 0.15rem;
@@ -940,8 +989,7 @@
}
#sa_card_json {
- flex: 1;
- min-height: 12rem;
+ min-height: 8rem;
width: 100%;
box-sizing: border-box;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -951,6 +999,182 @@
resize: vertical;
}
+.sa-card-form {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.sa-card-form label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ font-size: 0.78rem;
+ opacity: 0.92;
+}
+
+.sa-card-form input,
+.sa-card-form textarea {
+ width: 100%;
+ box-sizing: border-box;
+ border-radius: 0.35rem;
+ padding: 0.35rem 0.45rem;
+ font: inherit;
+}
+
+.sa-card-form .sa-card-json-toggle {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.sa-card-previews {
+ display: flex;
+ gap: 0.35rem;
+ overflow-x: auto;
+ padding: 0.15rem 0;
+ min-height: 4.5rem;
+}
+
+.sa-card-thumb {
+ width: 4.5rem;
+ height: 4.5rem;
+ object-fit: cover;
+ border-radius: 0.35rem;
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
+ cursor: pointer;
+ flex: 0 0 auto;
+ background: color-mix(in srgb, currentColor 6%, transparent);
+}
+
+.sa-card-thumb:hover {
+ border-color: color-mix(in srgb, currentColor 55%, transparent);
+}
+
+.sa-card-row {
+ display: flex;
+ gap: 0.45rem;
+ align-items: flex-start;
+}
+
+.sa-card-row-thumb {
+ width: 2.4rem;
+ height: 2.4rem;
+ border-radius: 0.3rem;
+ object-fit: cover;
+ flex: 0 0 auto;
+ background: color-mix(in srgb, currentColor 8%, transparent);
+ border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
+}
+
+.sa-card-row-body {
+ min-width: 0;
+ flex: 1;
+}
+
+.sa-lora-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ align-items: center;
+ min-height: 0;
+}
+
+.sa-lora-chip {
+ appearance: none;
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+ background: color-mix(in srgb, currentColor 8%, transparent);
+ color: inherit;
+ border-radius: 999px;
+ padding: 0.12rem 0.45rem;
+ font-size: 0.72rem;
+ cursor: pointer;
+ max-width: 14rem;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sa-lora-chip.sa-lora-add {
+ opacity: 0.75;
+ border-style: dashed;
+}
+
+.sa-lora-picker {
+ position: absolute;
+ z-index: 40;
+ max-height: 12rem;
+ overflow: auto;
+ min-width: 14rem;
+ max-width: 22rem;
+ border-radius: 0.45rem;
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+ background: color-mix(in srgb, #111 92%, transparent);
+ box-shadow: 0 8px 24px color-mix(in srgb, #000 35%, transparent);
+ padding: 0.25rem;
+}
+
+.sa-lora-picker button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: transparent;
+ color: inherit;
+ padding: 0.35rem 0.45rem;
+ border-radius: 0.3rem;
+ cursor: pointer;
+ font-size: 0.78rem;
+}
+
+.sa-lora-picker button:hover {
+ background: color-mix(in srgb, currentColor 12%, transparent);
+}
+
+.sa-slash-wrap {
+ position: relative;
+}
+
+.sa-slash-menu {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: calc(100% + 0.25rem);
+ z-index: 50;
+ max-height: 12rem;
+ overflow: auto;
+ border-radius: 0.45rem;
+ border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
+ background: color-mix(in srgb, #111 94%, transparent);
+ box-shadow: 0 8px 24px color-mix(in srgb, #000 40%, transparent);
+ padding: 0.2rem;
+}
+
+.sa-slash-item {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: none;
+ background: transparent;
+ color: inherit;
+ padding: 0.35rem 0.5rem;
+ border-radius: 0.3rem;
+ cursor: pointer;
+ font-size: 0.8rem;
+}
+
+.sa-slash-item code {
+ font-weight: 650;
+}
+
+.sa-slash-item.sa-slash-active,
+.sa-slash-item:hover {
+ background: color-mix(in srgb, currentColor 14%, transparent);
+}
+
.sa-cards-actions {
display: flex;
flex-wrap: wrap;
diff --git a/Assets/assistent.js b/Assets/assistent.js
index 90e2219..e62ab4b 100644
--- a/Assets/assistent.js
+++ b/Assets/assistent.js
@@ -1,6 +1,6 @@
/**
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
- * v0.5.3: live inventory+cards in context, taste memory, post-download cards, wanted YAML fix.
+ * v0.6.0: board tabs, Cards form+Civitai fetch, LoRA chips, taste on disk, reliability fixes.
*/
(function () {
const LS_BASE = 'swarm_assistent_base_url';
@@ -16,9 +16,12 @@
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_BOARD_TAB = 'swarm_assistent_board_tab';
const TAB_BUTTON_ID = 'maintab_assistent';
const GEN_ID = 'generate';
const MAX_REF_SLOTS = 4;
+ const CONTEXT_PROMPT_MAX = 2000;
const ASPECT_TABLE = {
'1:1': [1024, 1024],
@@ -52,11 +55,11 @@
const WELCOME_HTML = `
Assistent · Krea 2
- - Generate слева — живой просмотр текущей генерации. В чат сам не уходит.
- - Ref — референсы: drop / paste / Snapshot gen / Send to Assistent.
- - Глаз на окне — отправить это изображение мне в vision.
- - Чипсы aspect / seed / Vary — быстрые патчи. В чате:
/help.
- - Кнопки патча только у последнего предложения. Пока идёт генерация, Apply + Generate крутит спиннер.
+ - Generate слева — живой просмотр. В чат сам не уходит.
+ - Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.
+ - Галочка vision на окне — отправить кадр модели.
+ - Чипсы aspect / seed / Vary / Turbo·RAW. В чате:
/help.
+ - Кнопки патча только у последнего предложения.
Напиши, что сгенерировать — или кинь референс и попроси правку.`;
@@ -69,23 +72,42 @@
/aspect 16:9 — размер из таблицы 1K
/seed lock|random — зафиксировать или рандомизировать seed
/vary — новый seed, тот же промпт
-/pack write|critique|compose|params|inpaint|describe
+/pack write|critique|compose|params|inpaint|describe|card
/civitai — поиск LoRA (Confirm в чате)
/inventory — rescan моделей + обновить список LoRA
-Чипсы над полем ввода делают то же для aspect / seed / vary.`;
+Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.`;
+
+ const SLASH_COMMANDS = [
+ { cmd: '/help', hint: 'список команд' },
+ { 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,
busy: false,
generating: false,
+ chatEpoch: 0,
+ waitImageTimer: null,
lastImageDataUrl: null,
preferredModel: null,
dragDepth: 0,
inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
inventoryFetchedAt: 0,
taste: { styles: [], likes: [], avoid: [], notes: '', updated: 0 },
+ tasteSaveTimer: null,
streamEl: null,
streamMeta: null,
critiqueHopUsed: false,
@@ -99,10 +121,13 @@
refSeq: 1,
packUserTouched: false,
view: 'chat',
+ boardTab: 'generate',
personas: [],
modelCards: {},
cardsSelection: null,
cardsBusy: false,
+ pendingPersonaNote: null,
+ slashIndex: 0,
};
function $(id) {
@@ -562,24 +587,30 @@
function snapshotGenerateToRef() {
const src = generateSlot()?.src || findCurrentGenerateSrc({ allowPreview: true });
if (!src) {
- setStatus('No current Generate image');
+ setStatus('Нет текущего кадра Generate');
return false;
}
const empty = refSlots().find((s) => !s.src);
+ let ok = false;
if (empty) {
- return setSlotSrc(empty.id, src, { note: `Snapshot → ${empty.label}` });
+ 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} (замена)` });
+ }
+ }
}
- const created = addRefSlot({ src, select: true });
- if (created?.src) {
- setStatus(`Snapshot → ${created.label}`);
- flashImagePane(created.id);
- return true;
+ if (ok) {
+ setBoardTab('refs');
}
- const last = refSlots()[refSlots().length - 1];
- if (last) {
- return setSlotSrc(last.id, src, { note: `Snapshot → ${last.label} (replaced)` });
- }
- return false;
+ return ok;
}
function putImageOnBoard(src, { note = null, switchTab = false, preferSelected = true } = {}) {
@@ -730,10 +761,10 @@
const actions = document.createElement('div');
actions.className = 'sa-patch-actions sa-patch-current';
for (const [label, which] of [
- ['Apply all', 'all'],
- ['Prompt', 'prompt'],
+ ['Применить всё', 'all'],
+ ['Промпт', 'prompt'],
['LoRAs', 'loras'],
- ['Params', 'params'],
+ ['Параметры', 'params'],
]) {
const btn = document.createElement('button');
btn.type = 'button';
@@ -745,13 +776,13 @@
const genBtn = document.createElement('button');
genBtn.type = 'button';
genBtn.className = 'basic-button sa-btn-gen';
- genBtn.textContent = 'Apply + Generate';
+ genBtn.textContent = 'Применить + Generate';
genBtn.addEventListener('click', async () => {
if (isGenerateUnavailable()) {
return;
}
await applyPatch(patch, 'all');
- await runGenerateFromPatch({ ...patch, actions: ['generate'] });
+ await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true });
});
actions.appendChild(genBtn);
host.appendChild(actions);
@@ -764,17 +795,22 @@
return;
}
ensureBoard();
+ const tab = state.boardTab === 'refs' ? 'refs' : 'generate';
const refs = refSlots();
- board.classList.toggle('sa-board-many', refs.some((s) => s.src) || refs.length > 1);
+ 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 = '';
- for (const slot of state.slots) {
+ 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 (refs.length < MAX_REF_SLOTS) {
+ if (tab === 'refs' && refs.length < MAX_REF_SLOTS) {
const add = document.createElement('div');
add.className = 'sa-add-cell';
add.textContent = '+ Ref';
- add.title = 'Add a reference window';
+ add.title = 'Добавить окно референса';
add.addEventListener('click', (e) => {
e.stopPropagation();
addRefSlot({ select: true });
@@ -794,10 +830,53 @@
});
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' : ''}`;
@@ -840,8 +919,8 @@
const empty = document.createElement('div');
empty.className = 'sa-image-empty';
empty.innerHTML = slot.type === 'generate'
- ? 'Generate
Live view of the current generation
'
- : 'Reference
Drop · paste · Snapshot gen
';
+ ? 'Generate
Живой просмотр текущей генерации
'
+ : 'Reference
Drop · paste · Снимок gen
';
el.appendChild(empty);
}
@@ -872,6 +951,7 @@
if (slot.type === 'generate') {
const created = addRefSlot({ select: true });
await handleDropDataTransfer(e.dataTransfer, created?.id);
+ setBoardTab('refs');
} else {
await handleDropDataTransfer(e.dataTransfer, targetId);
}
@@ -922,6 +1002,149 @@
box.scrollTop = box.scrollHeight;
}
+ function persistHistory() {
+ try {
+ const slim = (state.history || [])
+ .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish)
+ .slice(-24)
+ .map((m) => ({
+ role: m.role,
+ content: String(m.content || '').slice(0, 4000),
+ persona: m.persona || undefined,
+ pack: m.pack || undefined,
+ }));
+ localStorage.setItem(LS_HISTORY, JSON.stringify(slim));
+ } catch (e) { /* ignore */ }
+ }
+
+ function restoreHistory() {
+ try {
+ const raw = localStorage.getItem(LS_HISTORY);
+ if (!raw) {
+ return;
+ }
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed) || !parsed.length) {
+ return;
+ }
+ state.history = parsed
+ .filter((m) => m && (m.role === 'user' || m.role === 'assistant') && m.content)
+ .slice(-24)
+ .map((m) => ({
+ role: m.role,
+ content: String(m.content),
+ persona: m.persona,
+ pack: m.pack,
+ }));
+ const box = $('sa_messages');
+ if (!box || !state.history.length) {
+ return;
+ }
+ hideChatEmpty();
+ for (const m of state.history) {
+ if (m.role === 'user') {
+ appendMessage('user', m.content);
+ } else {
+ appendMessage('assistant', m.content, null, null, {
+ persona: m.persona ? { id: m.persona, title: m.persona } : null,
+ pack: m.pack,
+ });
+ }
+ }
+ } catch (e) { /* ignore */ }
+ }
+
+ function clearPersistedHistory() {
+ try {
+ localStorage.removeItem(LS_HISTORY);
+ } catch (e) { /* ignore */ }
+ }
+
+ function hideSlashMenu() {
+ const menu = $('sa_slash_menu');
+ if (menu) {
+ menu.hidden = true;
+ menu.innerHTML = '';
+ }
+ state.slashIndex = 0;
+ }
+
+ function slashMatches(text) {
+ const t = String(text || '');
+ if (!t.startsWith('/')) {
+ return [];
+ }
+ const q = t.toLowerCase();
+ return SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q) || q === '/' || c.cmd.toLowerCase().includes(q.slice(1)));
+ }
+
+ function renderSlashMenu(items) {
+ const menu = $('sa_slash_menu');
+ if (!menu) {
+ return;
+ }
+ if (!items.length) {
+ hideSlashMenu();
+ return;
+ }
+ menu.hidden = false;
+ menu.innerHTML = '';
+ state.slashIndex = Math.max(0, Math.min(state.slashIndex, items.length - 1));
+ items.forEach((item, i) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-slash-item' + (i === state.slashIndex ? ' sa-slash-active' : '');
+ btn.setAttribute('role', 'option');
+ btn.innerHTML = `${escapeHtml(item.cmd.trim())} — ${escapeHtml(item.hint)}`;
+ btn.addEventListener('mousedown', (e) => {
+ e.preventDefault();
+ applySlashPick(item);
+ });
+ menu.appendChild(btn);
+ });
+ }
+
+ function applySlashPick(item) {
+ const input = $('sa_input');
+ if (!input || !item) {
+ return;
+ }
+ input.value = item.cmd;
+ hideSlashMenu();
+ input.focus();
+ const pos = input.value.length;
+ input.setSelectionRange(pos, pos);
+ }
+
+ function updateSlashMenuFromInput() {
+ const text = $('sa_input')?.value || '';
+ if (!text.startsWith('/') || text.includes('\n') || /\s/.test(text.trim().slice(1)) && !text.endsWith(' ')) {
+ // show while typing command token only
+ const token = text.split(/\s/)[0] || '';
+ if (!token.startsWith('/') || (text.includes(' ') && !SLASH_COMMANDS.some((c) => c.cmd.startsWith(token)))) {
+ if (!(token.startsWith('/') && !text.includes(' '))) {
+ hideSlashMenu();
+ return;
+ }
+ }
+ }
+ const token = (text.split(/\s/)[0] || '');
+ if (!token.startsWith('/') || text.indexOf(' ') > 0) {
+ hideSlashMenu();
+ return;
+ }
+ renderSlashMenu(slashMatches(token));
+ }
+
+ function onPersonaChanged() {
+ const id = $('sa_persona')?.value || 'neutral';
+ const info = (state.personas || []).find((p) => p.id === id);
+ const title = info?.title || id;
+ saveSettings();
+ appendSystemNote(`Тон → ${title}`);
+ state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
+ }
+
function countPromptImages() {
try {
const box = document.getElementById('alt_prompt_textbox') || document.getElementById('input_prompt');
@@ -1096,14 +1319,25 @@
};
}
+ function slimPromptForContext(raw) {
+ let s = String(raw || '');
+ s = s.replace(/data:image\/[a-zA-Z0-9.+-]+;base64,[A-Za-z0-9+/=]+/g, '[image omitted]');
+ s = s.replace(/]*>[\s\S]*?<\/image>/gi, '[image omitted]');
+ s = s.replace(/
]*>/gi, '[image omitted]');
+ if (s.length > CONTEXT_PROMPT_MAX) {
+ s = s.slice(0, CONTEXT_PROMPT_MAX) + '…';
+ }
+ return s;
+ }
+
function collectLiveContext() {
const inv = state.inventory || {};
const initCtx = readInitContext();
const ctx = {
architecture_ok: isKreaSelected(),
checkpoint: null,
- prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
- negative: val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '',
+ 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,
@@ -1224,6 +1458,20 @@
} 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);
+ ctx.krea_profile = hasRaw && !hasTurbo ? 'raw' : 'turbo';
+ ctx.recommended_params = ctx.krea_profile === 'raw'
+ ? { steps: 28, cfg: 4.5 }
+ : { steps: 8, cfg: 1, sigma_shift: 1.15 };
+ } catch (e) {
+ ctx.krea_profile = 'turbo';
+ ctx.recommended_params = { steps: 8, cfg: 1, sigma_shift: 1.15 };
+ }
+
return ctx;
}
@@ -1374,6 +1622,7 @@
try {
localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
} catch (e) { /* ignore */ }
+ saveTasteToServerDebounced();
}
function pushUnique(arr, value, max) {
@@ -1465,11 +1714,7 @@
obj.movement != null ||
obj.clear_prompt_images != null ||
obj.slot_to_prompt_image != null ||
- obj.pack != null ||
- obj.triggers != null ||
- obj.when != null ||
- obj.prompt_hint != null ||
- obj.notes != null
+ obj.pack != null
);
}
@@ -1477,10 +1722,14 @@
if (!obj || typeof obj !== 'object') {
return false;
}
- return (
- (obj.kind || obj.triggers || obj.when || obj.prompt_hint || obj.notes) &&
- (obj.name || obj.triggers || obj.when)
- );
+ // Prefer card shape over gen patch when both could match.
+ const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
+ const genish = !!(obj.prompt != null || obj.loras || obj.actions || obj.width || obj.height
+ || obj.steps || obj.cfg || obj.aspect || obj.seed != null);
+ if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
+ return true;
+ }
+ return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
}
function extractCardJson(text) {
@@ -1892,7 +2141,21 @@
return false;
}
+ function cancelWaitForNewImage() {
+ if (state.waitImageTimer) {
+ clearInterval(state.waitImageTimer);
+ state.waitImageTimer = null;
+ }
+ }
+
+ function bumpChatEpoch() {
+ state.chatEpoch = (state.chatEpoch || 0) + 1;
+ return state.chatEpoch;
+ }
+
function doInterruptNow() {
+ bumpChatEpoch();
+ cancelWaitForNewImage();
try {
if (typeof doInterrupt === 'function') {
doInterrupt(false);
@@ -1905,27 +2168,35 @@
}
function waitForNewImage(prevSrc, timeoutMs = 180000) {
+ cancelWaitForNewImage();
+ const epoch = state.chatEpoch;
return new Promise((resolve) => {
const start = Date.now();
- const timer = setInterval(() => {
+ state.waitImageTimer = setInterval(() => {
+ if (epoch !== state.chatEpoch) {
+ cancelWaitForNewImage();
+ resolve(null);
+ return;
+ }
const src = findCurrentGenerateSrc();
if (src && src !== prevSrc && !looksLikeModelPreview(src)) {
- clearInterval(timer);
+ cancelWaitForNewImage();
resolve(src);
} else if (Date.now() - start > timeoutMs) {
- clearInterval(timer);
+ cancelWaitForNewImage();
resolve(null);
}
}, 400);
});
}
- async function runGenerateFromPatch(patch) {
- if (!$('sa_auto_generate')?.checked || !patchHasGenTrigger(patch)) {
+ async function runGenerateFromPatch(patch, opts = {}) {
+ const force = !!opts.force;
+ if ((!force && !$('sa_auto_generate')?.checked) || !patchHasGenTrigger(patch)) {
return null;
}
const prev = findCurrentGenerateSrc();
- setStatus('Generating…');
+ setStatus('Генерация…');
startBusyUi('generating');
state.generating = true;
setInterruptVisible(true);
@@ -1933,14 +2204,14 @@
if (!ok) {
state.generating = false;
setInterruptVisible(state.busy);
- setStatus('Could not start Generate (UI hook missing)');
+ setStatus('Не удалось запустить Generate');
return null;
}
const src = await waitForNewImage(prev);
state.generating = false;
setInterruptVisible(state.busy);
if (!state.busy) {
- stopBusyUi(src ? 'Generate done' : 'Generate finished (no new image detected)');
+ stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
}
if (src) {
const gen = generateSlot();
@@ -1948,11 +2219,11 @@
gen.src = src;
renderBoard();
}
- setStatus('Generate done');
+ setStatus('Generate готов');
return src;
}
if (state.busy) {
- setStatus('Generate finished (no new image detected)');
+ setStatus('Generate завершён (новое изображение не найдено)');
}
return null;
}
@@ -2127,10 +2398,11 @@
}
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) {
+ if (patch && !isCardObject(patch) && !(card && !patch.prompt && !patch.actions && !patch.loras)) {
const wrap = document.createElement('div');
wrap.className = 'sa-patch';
const pre = document.createElement('pre');
@@ -2138,6 +2410,13 @@
wrap.appendChild(pre);
mountPatchActions(wrap, patch);
el.appendChild(wrap);
+ } 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));
@@ -2492,6 +2771,10 @@
if (view === 'cards' || view === 'chat') {
state.view = view;
}
+ const boardTab = localStorage.getItem(LS_BOARD_TAB);
+ if (boardTab === 'refs' || boardTab === 'generate') {
+ state.boardTab = boardTab;
+ }
}
function saveSettings() {
@@ -2751,12 +3034,29 @@
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 });
+ 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 });
+ 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;
@@ -2770,7 +3070,7 @@
root.innerHTML = '';
const rows = cardsCatalog();
if (!rows.length) {
- root.innerHTML = 'Inventory empty — Refresh.
';
+ root.innerHTML = 'Inventory пуст — Обновить.
';
return;
}
for (const row of rows) {
@@ -2780,18 +3080,236 @@
if (state.cardsSelection && state.cardsSelection.kind === row.kind && state.cardsSelection.name === row.name) {
btn.classList.add('sa-selected');
}
- btn.innerHTML = `${row.kind}
${escapeHtml(row.title || row.name)}
${row.has_card ? 'card ✓' : 'no card'}${row.trigger ? ' · ' + escapeHtml(String(row.trigger).slice(0, 40)) : ''}
`;
+ const thumb = row.preview_url
+ ? `
`
+ : '';
+ const metaBits = [];
+ metaBits.push(row.has_card ? 'card ✓' : 'нет card');
+ if (row.has_sidecar) {
+ metaBits.push('sidecar');
+ }
+ if (row.trigger) {
+ metaBits.push(String(row.trigger).slice(0, 40));
+ }
+ btn.innerHTML = `${thumb}${escapeHtml(row.kind)}
${escapeHtml(row.title || row.name)}
${escapeHtml(metaBits.join(' · '))}
`;
btn.addEventListener('click', () => selectCardModel(row));
root.appendChild(btn);
}
}
- function escapeHtml(s) {
- return String(s || '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"');
+ 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) {
@@ -2803,40 +3321,31 @@
const badge = $('sa_card_badge');
if (badge) {
badge.hidden = false;
- badge.textContent = row.has_card ? 'has card' : 'missing card';
+ badge.textContent = '…';
}
- setCardStatus('Loading…');
+ setCardStatus('Читаю локальную мету…');
genericRequest(
'AssistentGetCardMeta',
- { kind: row.kind, name: row.name },
- async (data) => {
- const 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 ($('sa_card_json')) {
- $('sa_card_json').value = JSON.stringify(card, null, 2);
- }
- state.modelCards[`${row.kind}:${row.name}`] = card;
- // Attach 1–2 Civitai examples to board as Ref vision.
- const urls = (data.example_urls || []).slice(0, 2);
- for (const url of urls) {
- try {
- await addRefFromUrl(url);
- } catch (e) { /* ignore */ }
- }
- setCardStatus(data.has_card ? 'Loaded card' : 'No card yet — Generate or edit JSON');
- },
+ { kind: row.kind, name: row.name, fetch: false },
+ (data) => applyCardMetaResponse(row, data || {}),
0,
- (err) => setCardStatus(String(err || 'Load failed')),
+ (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: ошибка запроса')),
);
}
@@ -2848,19 +3357,23 @@
}
function readCardDraft() {
- const raw = $('sa_card_json')?.value || '';
- try {
- return JSON.parse(raw);
- } catch (e) {
- setCardStatus('Invalid JSON');
- return null;
+ 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('Select a model');
+ setCardStatus('Выбери модель');
return;
}
const card = readCardDraft();
@@ -2869,7 +3382,7 @@
}
card.kind = card.kind || sel.kind;
card.name = card.name || sel.name;
- setCardStatus('Saving…');
+ setCardStatus('Сохраняю…');
genericRequest(
'AssistentSaveCard',
{ kind: sel.kind, name: sel.name, card, enqueue_wanted: !!enqueue },
@@ -2879,11 +3392,13 @@
return;
}
state.modelCards[`${sel.kind}:${sel.name}`] = card;
- setCardStatus(data.installed ? `Saved ${data.path}` : `Draft + wanted → ${data.path}`);
- refreshInventory();
+ setCardStatus(data.installed
+ ? `Карточка Assistent сохранена · ${data.path}`
+ : `Черновик + wanted · ${data.path}`);
+ refreshInventory(() => renderCardsList());
},
0,
- (err) => setCardStatus(String(err || 'Save failed')),
+ (err) => setCardStatus(String(err || 'Ошибка сохранения')),
);
}
@@ -2891,7 +3406,7 @@
const sel = state.cardsSelection;
const card = readCardDraft() || {};
if (!sel && !card.civitai_url) {
- setCardStatus('Need model or civitai_url');
+ setCardStatus('Нужна модель или civitai_url');
return;
}
genericRequest(
@@ -2904,21 +3419,197 @@
card,
},
(data) => {
- setCardStatus(data.already ? 'Already in wanted queue' : `Wanted → ${data.path}`);
+ setCardStatus(data.already ? 'Уже в wanted' : `Wanted → ${data.path}`);
},
0,
- (err) => setCardStatus(String(err || 'Enqueue failed')),
+ (err) => setCardStatus(String(err || 'Ошибка enqueue')),
);
}
+ function shortLoraName(name) {
+ const s = String(name || '');
+ const base = s.split(/[/\\]/).pop() || s;
+ return base.replace(/\.safetensors$/i, '').slice(0, 28);
+ }
+
+ function renderLoraChips() {
+ const root = $('sa_lora_chips');
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '';
+ let selected = [];
+ try {
+ if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
+ selected = loraHelper.selected.map((l) => ({
+ name: l.name || l,
+ weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
+ }));
+ }
+ } catch (e) { /* ignore */ }
+ for (const l of selected) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-lora-chip';
+ btn.title = `${l.name} ×${l.weight} — клик снять`;
+ btn.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
+ btn.addEventListener('click', () => {
+ try {
+ if (typeof loraHelper !== 'undefined' && typeof loraHelper.removeLora === 'function') {
+ loraHelper.removeLora(l.name);
+ } else if (loraHelper?.selected) {
+ loraHelper.selected = loraHelper.selected.filter((x) => (x.name || x) !== l.name);
+ if (typeof loraHelper.rebuildUI === 'function') {
+ loraHelper.rebuildUI();
+ }
+ }
+ } catch (e) { /* ignore */ }
+ renderLoraChips();
+ });
+ root.appendChild(btn);
+ }
+ const add = document.createElement('button');
+ add.type = 'button';
+ add.className = 'sa-lora-chip sa-lora-add';
+ add.textContent = '+ LoRA';
+ add.title = 'Добавить из inventory';
+ add.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openLoraPicker(add);
+ });
+ root.appendChild(add);
+ }
+
+ function openLoraPicker(anchor) {
+ document.querySelectorAll('.sa-lora-picker').forEach((n) => n.remove());
+ const picker = document.createElement('div');
+ picker.className = 'sa-lora-picker';
+ const inv = (state.inventory?.loras || []).slice().sort((a, b) => (b.krea_likely ? 1 : 0) - (a.krea_likely ? 1 : 0));
+ const filter = document.createElement('input');
+ filter.type = 'search';
+ filter.placeholder = 'Фильтр LoRA…';
+ filter.style.cssText = 'width:100%;box-sizing:border-box;margin-bottom:0.25rem;padding:0.3rem;';
+ picker.appendChild(filter);
+ const list = document.createElement('div');
+ picker.appendChild(list);
+ const draw = () => {
+ list.innerHTML = '';
+ const q = filter.value.trim().toLowerCase();
+ let n = 0;
+ for (const l of inv) {
+ const name = l.name || '';
+ if (q && !String(name).toLowerCase().includes(q) && !String(l.title || '').toLowerCase().includes(q)) {
+ continue;
+ }
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.textContent = `${shortLoraName(name)}${l.krea_likely ? ' · krea' : ''}`;
+ btn.title = name;
+ btn.addEventListener('click', async () => {
+ await applyPatch({
+ loras: [
+ ...((() => {
+ try {
+ return (loraHelper?.selected || []).map((x) => ({
+ name: x.name || x,
+ weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[x.name || x]) || 1,
+ }));
+ } catch (e) {
+ return [];
+ }
+ })()),
+ { name, weight: l.default_weight ? parseFloat(l.default_weight) : 0.8, triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []) },
+ ],
+ }, 'loras');
+ picker.remove();
+ renderLoraChips();
+ });
+ list.appendChild(btn);
+ if (++n >= 40) {
+ break;
+ }
+ }
+ if (!n) {
+ list.innerHTML = 'Нет LoRA
';
+ }
+ };
+ filter.addEventListener('input', draw);
+ draw();
+ const composer = $('sa_composer') || document.body;
+ composer.style.position = composer.style.position || 'relative';
+ composer.appendChild(picker);
+ const onDoc = (ev) => {
+ if (!picker.contains(ev.target) && ev.target !== anchor) {
+ picker.remove();
+ document.removeEventListener('mousedown', onDoc);
+ }
+ };
+ setTimeout(() => document.addEventListener('mousedown', onDoc), 0);
+ filter.focus();
+ }
+
+ function loadTasteFromServer() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentGetTaste',
+ {},
+ (data) => {
+ const remote = data?.taste;
+ if (!remote || typeof remote !== 'object') {
+ return;
+ }
+ const remoteUpdated = remote.updated || 0;
+ const localUpdated = state.taste?.updated || 0;
+ if (remoteUpdated >= localUpdated) {
+ state.taste = {
+ styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
+ likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
+ avoid: Array.isArray(remote.avoid) ? remote.avoid.slice(0, 12) : [],
+ notes: String(remote.notes || '').slice(0, 400),
+ updated: remoteUpdated || Date.now(),
+ };
+ saveTasteLocalOnly();
+ }
+ },
+ 0,
+ () => {},
+ );
+ }
+
+ function saveTasteLocalOnly() {
+ try {
+ localStorage.setItem(LS_TASTE, JSON.stringify(state.taste || {}));
+ } catch (e) { /* ignore */ }
+ }
+
+ function saveTasteToServerDebounced() {
+ if (state.tasteSaveTimer) {
+ clearTimeout(state.tasteSaveTimer);
+ }
+ state.tasteSaveTimer = setTimeout(() => {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentSaveTaste',
+ { taste: state.taste || {} },
+ () => {},
+ 0,
+ () => {},
+ );
+ }, 800);
+ }
+
async function generateCardWithAssistent() {
const sel = state.cardsSelection;
if (!sel) {
- setCardStatus('Select a model');
+ setCardStatus('Выбери модель');
return;
}
if (state.busy) {
- setCardStatus('Chat busy');
+ setCardStatus('Чат занят');
return;
}
setPackValue('catalog_card', { flash: true });
@@ -3130,9 +3821,14 @@
const id = normalizeSlotId(arg || GEN_ID) || GEN_ID;
const slot = slotById(id);
if (!slot) {
- setStatus(`Unknown slot: ${arg || GEN_ID}`);
+ 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) {
@@ -3309,7 +4005,7 @@
}
if (!updateGate()) {
- setStatus('Select a Krea 2 model');
+ setStatus('Выбери модель Krea 2');
return;
}
@@ -3329,16 +4025,29 @@
const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
const model = $('sa_model')?.value;
if (!model) {
- setStatus('Pick an Ollama model in ⚙');
+ setStatus('Выбери модель Ollama в ⚙');
refreshModels();
return;
}
+ const chatEpoch = bumpChatEpoch();
+ state.busy = true;
+ setInterruptVisible(true);
+ startBusyUi('thinking');
+ saveSettings();
+
// Always pull latest LoRA/checkpoint list before the LLM sees context
// (rescans disk when inventory is older than ~20s or after downloads).
- setStatus('Refreshing inventory…');
- await ensureFreshInventory({ forceRescan: !!opts.fromDownload });
- await prefetchActiveModelCards();
+ 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;
@@ -3356,9 +4065,12 @@
let images = null;
if (visionSlots.length) {
startBusyUi('encoding');
- setStatus('Encoding image…');
+ setStatus('Кодирую изображение…');
images = [];
for (const slot of visionSlots) {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
const b64 = await imageToBase64ForOllama(slot.src);
if (b64) {
images.push(b64);
@@ -3368,14 +4080,22 @@
images = null;
}
}
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
const msgMeta = {
persona: currentPersonaInfo(),
pack,
};
state.history.push({ role: 'user', content: text });
+ if (state.pendingPersonaNote) {
+ state.history.push({ role: 'user', content: state.pendingPersonaNote, systemish: true });
+ state.pendingPersonaNote = null;
+ }
appendMessage('user', text);
$('sa_input').value = '';
+ persistHistory();
const context = collectLiveContext();
context.has_vision_image = !!images;
@@ -3389,6 +4109,9 @@
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;
@@ -3397,10 +4120,7 @@
messages[messages.length - 1].images = images;
}
- state.busy = true;
- setInterruptVisible(true);
startBusyUi('thinking');
- saveSettings();
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
// Flat fields: SwarmUI JObject params receive the whole request body.
@@ -3423,10 +4143,15 @@
};
const finishOk = async (reply, civitaiResults) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
state.busy = false;
setInterruptVisible(state.generating);
- state.history.push({ role: 'assistant', content: reply, persona, pack });
- stopBusyUi('Done');
+ const prose = extractPatch(reply).prose || reply;
+ state.history.push({ role: 'assistant', content: prose, persona, pack });
+ persistHistory();
+ stopBusyUi('Готово');
await handleReplySideEffects(reply, civitaiResults, {
...opts,
userText: text,
@@ -3435,6 +4160,9 @@
};
const finishErr = (msg) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
state.busy = false;
setInterruptVisible(state.generating);
stopBusyUi(msg);
@@ -3455,11 +4183,14 @@
'AssistentChatWS',
payload,
(data) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
if (data.phase === 'waiting_ollama') {
setBusyPhase('loading');
const label = state.streamEl?.querySelector('.sa-typing-label');
if (label) {
- label.textContent = `Loading ${modelShort(model)} into GPU…`;
+ label.textContent = `Загружаю ${modelShort(model)} в GPU…`;
}
return;
}
@@ -3471,7 +4202,7 @@
if (state.streamEl) {
state.streamEl.classList.add('sa-typing');
const body = state.streamEl.querySelector('.sa-msg-body') || state.streamEl;
- body.innerHTML = 'Refining…';
+ body.innerHTML = 'Уточняю…';
}
setBusyPhase('refining');
return;
@@ -3489,6 +4220,9 @@
},
0,
(err) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
// Fallback to HTTP AssistentChat
console.warn('AssistentChatWS failed, falling back', err);
if (state.streamEl) {
@@ -3500,6 +4234,9 @@
'AssistentChat',
payload,
(data) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
if (data.error) {
finishErr(String(data.error));
return;
@@ -3520,6 +4257,9 @@
'AssistentChat',
payload,
(data) => {
+ if (chatEpoch !== state.chatEpoch) {
+ return;
+ }
if (data.error) {
finishErr(String(data.error));
return;
@@ -3680,32 +4420,37 @@
window.__swarmAssistentWired = true;
loadSettings();
loadTaste();
+ loadTasteFromServer();
setView(state.view || 'chat');
updateGate();
ensureBoard();
- renderBoard();
+ setBoardTab(state.boardTab || 'generate', { persist: false });
syncGenerateSlot();
if (wantsAutoVision()) {
refreshImagePreview();
}
+ restoreHistory();
maybeWelcome();
refreshModels();
refreshPersonas();
- refreshInventory();
+ refreshInventory(() => {
+ renderCardsList();
+ renderLoraChips();
+ });
wireDropZone();
wireSplitter();
registerSendButton();
+ wireSlashInput();
+ wireCardForm();
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
- $('sa_persona')?.addEventListener('change', saveSettings);
+ $('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
+ $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
+ $('sa_persona')?.addEventListener('change', onPersonaChanged);
$('sa_cards_kind')?.addEventListener('change', renderCardsList);
$('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true }));
- $('sa_btn_card_meta')?.addEventListener('click', () => {
- if (state.cardsSelection) {
- selectCardModel(state.cardsSelection);
- }
- });
+ $('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());
@@ -3720,13 +4465,19 @@
saveSettings();
refreshModels();
});
- $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(null, { rescan: true }));
- $('sa_btn_add_ref')?.addEventListener('click', () => addRefSlot({ select: true }));
+ $('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 () => {
const src = selectedSrc() || findCurrentGenerateSrc();
if (!src) {
- setStatus('No selected/Generate image for Init');
+ setStatus('Нет изображения для Init');
return;
}
await setInitFromSrc(src);
@@ -3739,7 +4490,7 @@
$('sa_btn_as_mask')?.addEventListener('click', async () => {
const src = selectedSrc();
if (!src) {
- setStatus('Select a window with an image for Mask');
+ setStatus('Выбери окно с маской');
return;
}
await setMaskFromSrc(src);
@@ -3763,31 +4514,27 @@
state.streamEl.remove();
state.streamEl = null;
}
- stopBusyUi('Interrupted');
+ stopBusyUi('Прервано');
});
$('sa_btn_clear')?.addEventListener('click', () => {
state.history = [];
state.critiqueHopUsed = false;
state.visionHopUsed = false;
state.packUserTouched = false;
+ state.pendingPersonaNote = null;
+ clearPersistedHistory();
const box = $('sa_messages');
if (box) {
box.innerHTML = '';
const empty = document.createElement('div');
empty.className = 'sa-chat-empty';
empty.id = 'sa_chat_empty';
- empty.innerHTML = 'Collaborative Krea 2
Write a prompt, drop refs, use aspect chips, or type /help.
';
+ empty.innerHTML = 'Совместная работа с Krea 2
Напиши промпт, кинь refs, чипсы aspect или /help.
';
box.appendChild(empty);
}
stopBusyUi('');
setStatus('');
});
- $('sa_input')?.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey && !e.altKey) {
- e.preventDefault();
- sendChat();
- }
- });
$('sa_base_url')?.addEventListener('change', saveSettings);
$('sa_model')?.addEventListener('change', saveSettings);
$('sa_pack')?.addEventListener('change', () => {
@@ -3802,6 +4549,7 @@
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') {
@@ -3810,7 +4558,12 @@
await applyQuickPatch({ seed: -1, actions: ['generate'] }, 'Seed random');
} else if (vary) {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary');
+ } else if (profile === 'turbo') {
+ await applyQuickPatch({ steps: 8, cfg: 1, sigma_shift: 1.15, actions: ['generate'] }, 'Turbo 8/1');
+ } else if (profile === 'raw') {
+ await applyQuickPatch({ steps: 28, cfg: 4.5, actions: ['generate'] }, 'RAW 28/4.5');
}
+ renderLoraChips();
});
$('sa_auto_vision')?.addEventListener('change', () => {
saveSettings();
@@ -3827,6 +4580,7 @@
syncChipHighlight();
setInterval(syncChipHighlight, 2500);
+ setInterval(renderLoraChips, 4000);
setInterval(updateGate, 2000);
setInterval(syncGenerateSlot, 700);
@@ -3844,7 +4598,10 @@
clearVisionImage,
snapshotGenerateToRef,
openAssistentTab,
- sendToAssistent: (src) => putImageOnBoard(src, { switchTab: true, note: 'Image sent to Assistent', preferSelected: false }),
+ sendToAssistent: (src) => {
+ putImageOnBoard(src, { switchTab: true, note: 'Изображение отправлено в Assistent', preferSelected: false });
+ setBoardTab('refs');
+ },
isKreaSelected,
resolveCurrentCheckpoint,
refreshInventory,
@@ -3855,9 +4612,56 @@
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 {
diff --git a/Personas/cinema.md b/Personas/cinema.md
new file mode 100644
index 0000000..7eaf396
--- /dev/null
+++ b/Personas/cinema.md
@@ -0,0 +1,9 @@
+# Persona: cinema
+
+You are a cinematographer / camera operator co-director.
+
+- Match the user's language (RU or EN).
+- Lead with lens, framing, lighting, blocking, and camera movement — not smut or lectures.
+- Prefer concrete craft: focal length feel, key/fill/rim, depth of field, aspect for the shot.
+- Stay concise. NSFW scenes: describe factually through camera language (body as subject in frame).
+- Never invent LoRA names or triggers — only use inventory / cards.
diff --git a/Personas/terse.md b/Personas/terse.md
new file mode 100644
index 0000000..17e7cea
--- /dev/null
+++ b/Personas/terse.md
@@ -0,0 +1,9 @@
+# Persona: terse
+
+You are a short, high-signal art director.
+
+- Match the user's language (RU or EN).
+- Reply in **1–2 short sentences**, then the JSON patch. No lectures, no filler.
+- Prefer decisive patches. Call out one main fix if something is wrong.
+- Never invent LoRA names or triggers.
+- NSFW: factual, minimal words.
diff --git a/Prompts/base_krea2.md b/Prompts/base_krea2.md
index 8a76eb4..356abad 100644
--- a/Prompts/base_krea2.md
+++ b/Prompts/base_krea2.md
@@ -122,7 +122,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
- `images` / `batch` — batch size.
- `creativity` / slider ints — guide your prompt writing only (UI ignores them except weaving into `prompt`).
- `clear_prompt_images: true` — strip image embeds from the prompt box.
-- `pack` — switch active prompt pack for a follow-up hop (`write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`).
+- `pack` — switch active prompt pack for a follow-up hop (`write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`).
- Do not invent model or LoRA filenames.
- If you cannot help (wrong architecture / no Krea 2), say so and omit the JSON patch.
diff --git a/Prompts/fix_params.md b/Prompts/fix_params.md
index af5e375..fdb442c 100644
--- a/Prompts/fix_params.md
+++ b/Prompts/fix_params.md
@@ -6,6 +6,7 @@ Goal: adjust **generation parameters** for Krea 2 Turbo (or RAW if context says
- **Turbo:** steps 4–12 (default **8**), CFG **1** (never 0), sigma shift ~**1.15**.
- **RAW/base:** steps 20–52, CFG ~4–4.5 — only if checkpoint/context indicates Raw. If a turbo-distill LoRA is available, weight **0.6** is the usual photoreal compromise (UI LoRA only — no dual-sampler).
+- Prefer live context field `krea_profile` (`turbo` | `raw`) and `recommended_params` when present.
- **Aspect:** prefer patch field `aspect` (`1:1`, `4:5`, `2:3`, `16:9`, `9:16`, `4:3`, `3:2`, `2.35:1`) — UI maps to official 1K sizes. Else set width/height near 1024.
- **Batch:** `images` or `batch` (1–4 typical).
- **Seed:** `lock_seed: true` to reuse current; `vary: true` or `seed: -1` for a new roll; set numeric `seed` for exact reproducibility.
diff --git a/README.md b/README.md
index b46246a..c05db08 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,27 @@
# Swarm Assistent
-SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + multi-window board (live Generate + refs), LoRA/trigger awareness, applyable patches, **img2img / inpaint**, slash commands, aspect/seed chips, auto Generate, and Civitai search with Confirm.
+SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, personas, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
-**Version 0.5.0** — denser Krea 2 knowledge in prompt packs, new patch fields, `/help` slash commands, composer chips.
+**Version 0.6.0** — board tabs, Cards form + live Civitai meta, Interrupt cancel, slim history, LoRA chips, Turbo/RAW, taste on disk, RU UI.
## Layout
-- **Left — Board:** live **Generate** window + **Ref** windows (drop / paste / *Send to Assistent* / Snapshot gen). Per-window **vision** checkbox attaches that image to the next chat.
+- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`)
- **Splitter:** drag to resize panes
-- **Right (wider):** chat — first visit shows a short how-to. Only the **latest** JSON proposal keeps action buttons; Apply + Generate spins/disables while a generation is running.
-- **Chips** above the composer: aspect ratios, Seed lock / random, Vary.
-- **Top-right:** prompt pack + settings (Ollama URL, model, auto-apply / auto-generate)
+- **Right:** Chat | Cards; persona / pack / Ollama model; settings gear
+- **Chips:** aspect, Seed lock/−1, Vary, Turbo/RAW; LoRA chip row under them
+- Slash autocomplete when the composer starts with `/`
## UX
-- **Send to Assistent** under the current Generate image (and History) — adds a Ref window and opens the Assistent tab
-- Drag / paste onto a Ref (drop on Generate snapshots into a new Ref)
-- **Snapshot gen** copies the live Generate window into a Ref
-- **As Init** / **As Mask** / **Clear Init** — selected window → Swarm `Init Image` / `Mask Image`
-- Enter sends; Shift+Enter newline
-- **Auto-apply** + **Auto-generate** (default on): patch from the LLM is applied and Generate runs when the patch changes prompt/params or includes `actions: ["generate"]`
-- Pure Q&A without a patch does **not** start Generate
-- **Interrupt** stops Swarm generation / clears busy state
-- **Civitai** search cards require **Confirm download** (uses Swarm `DoModelDownloadWS` + stored `civitai_api` key). Auto-download is off by default.
-- Pack auto-selects from the user message (critique / inpaint / params / compose / describe / write) unless you changed the dropdown yourself.
+- **Send to Assistent** under Generate/History → Ref + Assistent tab
+- Drop on Generate → new Ref + switch to Refs tab
+- **As Init** / **As Mask** / **Clear Init**
+- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch (no late auto-apply)
+- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
+- Auto-critique rewrites prompt only (no second Generate)
+- Civitai Confirm required (unless auto-download)
+- Cards: form fields + previews; **Load Civitai meta** fetches by SHA (`by-hash`); status always explicit
### Slash commands (client-side, no LLM)
@@ -33,24 +31,23 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
| `/gen` | Generate now |
| `/look generate\|refN` | Attach that board window + ask the LLM to look |
| `/init` `/mask` `/clear` | Same as board buttons |
-| `/interrupt` | Stop generation |
+| `/interrupt` | Stop generation / cancel chat |
| `/aspect 16:9` | Set size from the official 1K table |
| `/seed lock\|random` | Lock or randomize seed |
| `/vary` | New seed, same prompt (+ generate if auto) |
-| `/pack write\|critique\|compose\|params\|inpaint\|describe` | Switch pack |
+| `/pack write\|critique\|compose\|params\|inpaint\|describe\|card` | Switch pack |
| `/civitai ` | Ask LLM to search Civitai (Confirm still required) |
+| `/inventory` | Rescan models + refresh LoRA list |
## Requirements
- SwarmUI with a **Krea 2** checkpoint selected
-- Ollama on `http://127.0.0.1:11434` **on the GPU VM** (gpu-rent `LLM_RUNTIME=ollama`). The browser talks to SwarmUI; SwarmUI proxies `/api/tags` and `/api/chat`. URL in settings must stay `127.0.0.1:11434`, not the laptop tunnel port 17811.
-- At least one pulled model (`ollama pull` / `ollama-models.yaml`). Empty `/api/tags` → empty Model dropdown.
-- Optional: Civitai API key in SwarmUI User Settings for search/download.
+- Ollama on `http://127.0.0.1:11434` **on the GPU VM** (gpu-rent `LLM_RUNTIME=ollama`)
+- At least one pulled model
+- Optional: Civitai API key in SwarmUI User Settings
## Install
-Clone into SwarmUI `src/Extensions/swarm-assistent` (or let **gpu-rent** seed it from `extensions.yaml` with `requires: ollama`):
-
```yaml
swarmui:
- url: https://gitea.hsrv.site/mrleo1nid/swarm-assistent.git
@@ -59,81 +56,40 @@ swarmui:
requires: ollama
```
-Restart / rebuild SwarmUI after clone.
+Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
## Prompt packs
| Pack | Role |
| --- | --- |
-| `base_krea2` | Always injected: Krea 2 rules + JSON patch / actions contract |
-| `write_prompt` | Craft / improve prompts (prose structure) |
-| `critique_image` | Vision critique → fixes |
-| `compose_scene` | Scene / moodboard via board refs → text |
-| `fix_params` | Aspect / steps / CFG / seed / batch |
-| `inpaint_edit` | Init Image img2img + Mask inpaint |
-| `describe_ref` | Vision → Krea prompt (no generate by default) |
-| `catalog_card` | Recommendation card JSON for a checkpoint/LoRA |
+| `base_krea2` | Always injected |
+| `write_prompt` | Craft / improve prompts |
+| `critique_image` | Vision critique |
+| `compose_scene` | Scene via board refs |
+| `fix_params` | Aspect / steps / CFG / seed (`krea_profile` in live context) |
+| `inpaint_edit` | Init + Mask |
+| `describe_ref` | Vision → prompt |
+| `catalog_card` | Recommendation card JSON |
-**Persona** (tone) is a separate dropdown from pack — `base_krea2` → persona → pack. Overlay: `/mnt/swarm_data/Assistent/personas.json` (seeded from gpu-rent `assistent-personas.yaml`).
+**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`. Overlay: `/mnt/swarm_data/Assistent/personas.json` (gpu-rent seeds from `assistent-personas.yaml` → yaml + json). Assistent reads **json** only.
-**Cards** subtab: edit/save `{stem}.assistent.json` next to weights; live chat gets cards for the current checkpoint + enabled LoRAs only. Uninstalled models can enqueue `.gpu-rent-wanted-models.yaml` for the next `up`.
+**Taste:** `/mnt/swarm_data/Assistent/taste.json` via `AssistentGetTaste` / `AssistentSaveTaste` (merged with browser localStorage by `updated`).
-Live context (checkpoint, server inventory LoRAs + blurbs/triggers, `model_cards`, `taste_profile`, wildcards, current params, persona) is injected every request. Inventory refreshes each chat turn (disk rescan when stale / after download / `/inventory`). After Confirm download Assistent auto-writes a `.assistent.json` card.
-
-**Wanted queue:** Cards → Enqueue wanted → `/mnt/swarm_data/.gpu-rent-wanted-models.yaml`. Laptop: `gpu-rent capture wanted` (also on `capture models` / seed `up`).
-
-**Cloud-only Krea.ai features** (moodboards UI, Generative Sliders, Creativity Raw/Low/Medium/High) are **not** in Swarm. The assistant emulates them with prompt language + board refs. Optional patch fields `creativity` / `intensity` / `complexity` / `movement` guide the LLM only.
-
-### Patch actions
-
-```json
-{
- "prompt": "...",
- "loras": [{"name": "exact", "weight": 0.8, "triggers": ["..."]}],
- "aspect": "16:9",
- "width": 1376, "height": 768,
- "steps": 8, "cfg": 1,
- "seed": -1, "images": 1,
- "sigma_shift": 1.15,
- "creativity": "medium",
- "intensity": 0, "complexity": 0, "movement": 0,
- "vary": false, "lock_seed": false,
- "use_init_image": true,
- "init_creativity": 0.45,
- "use_mask_image": false,
- "clear_prompt_images": false,
- "look_at": ["generate"],
- "slot_to_init": "generate",
- "snapshot_generate": false,
- "pack": "critique_image",
- "actions": ["generate"],
- "search_query": null
-}
-```
-
-- `aspect` — maps to official 1K sizes (`1:1`, `4:5`, `2:3`, `16:9`, `9:16`, `4:3`, `3:2`, `2.35:1`)
-- `vary` / `lock_seed` / `images`|`batch` — seed and batch helpers
-- `clear_prompt_images` — strip `` embeds from the prompt box
-- `pack` — switch the active prompt pack for a follow-up
-- `generate` — auto-generate after apply (when enabled)
-- `look_at` — hop vision from named board windows (`generate`, `ref1`, …)
-- `slot_to_init` / `slot_to_mask` — copy that window into Swarm Init / Mask
-- `snapshot_generate` — copy live Generate into a Ref
-- `search_civitai` + `search_query` — server searches Civitai, second LLM hop, Confirm cards in UI
-- `interrupt` — stop current generation
-
-Mask convention: **white = edit**, black = keep. Creativity ≈ denoise (0–1).
-
-Turbo defaults: steps **8**, CFG **1** (never 0), sigma shift **1.15**.
+**Cards:** `{stem}.assistent.json` next to weights; Civitai sidecar `{stem}.civitai.json`. Wanted queue → `.gpu-rent-wanted-models.yaml`.
## API routes
| Route | Role |
| --- | --- |
| `AssistentListModels` | Ollama `/api/tags` |
-| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory from Swarm |
-| `AssistentSearchCivitai` | Civitai LoRA search |
+| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
+| `AssistentListPersonas` | Bundled + overlay personas |
| `AssistentGetPacks` | Prompt pack texts |
+| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards |
+| `AssistentGetCardMeta` | Local sidecar + optional `fetch=true` Civitai by-hash |
+| `AssistentEnqueueWanted` | Wanted YAML queue |
+| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
+| `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` | HTTP chat (+ Civitai hop) |
| `AssistentChatWS` | Streaming chat WebSocket |
diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs
index 2d39684..6f65268 100644
--- a/SwarmAssistentExtension.cs
+++ b/SwarmAssistentExtension.cs
@@ -42,7 +42,7 @@ public class SwarmAssistentExtension : Extension
"catalog_card",
];
- public static readonly string[] DefaultPersonaIds = ["neutral", "lewd", "aggressive"];
+ public static readonly string[] DefaultPersonaIds = ["neutral", "lewd", "aggressive", "cinema", "terse"];
const int MaxCivitaiHops = 2;
const int MaxLorasInInventory = 150;
@@ -61,7 +61,7 @@ public class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, multi-window board, personas, model cards, Generate loop, Civitai Confirm.";
License = "MIT";
- Version = "0.5.3";
+ Version = "0.6.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"];
}
@@ -77,6 +77,8 @@ public class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentEnqueueWanted, true, PermUse);
API.RegisterAPICall(AssistentGetCardMeta, false, PermUse);
API.RegisterAPICall(AssistentSearchCivitai, false, PermUse);
+ API.RegisterAPICall(AssistentGetTaste, false, PermUse);
+ API.RegisterAPICall(AssistentSaveTaste, true, PermUse);
API.RegisterAPICall(AssistentChat, true, PermUse);
API.RegisterAPICall(AssistentChatWS, true, PermUse);
Logs.Init("Swarm Assistent extension loaded (Ollama proxy + personas + model cards)");
@@ -217,6 +219,8 @@ public class SwarmAssistentExtension : Extension
{
"lewd" => "Пошляк",
"aggressive" => "Агрессивный",
+ "cinema" => "Кинооператор",
+ "terse" => "Короткий",
_ => "Нейтральный",
},
["prompt"] = text,
@@ -247,11 +251,19 @@ public class SwarmAssistentExtension : Extension
{
continue;
}
+ string overlayPrompt = po["prompt"]?.ToString() ?? "";
+ if (string.IsNullOrWhiteSpace(overlayPrompt) && byId.ContainsKey(id))
+ {
+ // Keep bundled prompt when overlay prompt is empty.
+ byId[id]["title"] = po["title"]?.ToString() ?? byId[id]["title"];
+ byId[id]["source"] = "overlay+bundled";
+ continue;
+ }
byId[id] = new JObject
{
["id"] = id,
["title"] = po["title"]?.ToString() ?? id,
- ["prompt"] = po["prompt"]?.ToString() ?? "",
+ ["prompt"] = overlayPrompt,
["source"] = "overlay",
};
}
@@ -401,7 +413,8 @@ public class SwarmAssistentExtension : Extension
// Not installed — draft into wanted-cards + optionally enqueue download for next up.
Directory.CreateDirectory(WantedCardsDir());
- string vid = card["version_id"]?.ToString() ?? "draft";
+ string rawVid = card["version_id"]?.ToString() ?? "draft";
+ string vid = Regex.IsMatch(rawVid, @"^\d+$") ? rawVid : "draft";
string draft = Path.Combine(WantedCardsDir(), $"{vid}.assistent.json");
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
if (enqueue_wanted || !string.IsNullOrWhiteSpace(card["civitai_url"]?.ToString()))
@@ -576,14 +589,56 @@ public class SwarmAssistentExtension : Extension
return sb.ToString();
}
- public async Task AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0)
+ string TasteJsonPath() => Path.Combine(DataRoot(), "Assistent", "taste.json");
+
+ public async Task AssistentGetTaste(Session session)
{
- // Pull Civitai sidecar next to weight + optional API version for examples.
await Task.CompletedTask;
+ string path = TasteJsonPath();
+ if (!File.Exists(path))
+ {
+ return new JObject { ["success"] = true, ["taste"] = null };
+ }
+ try
+ {
+ JObject taste = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
+ return new JObject { ["success"] = true, ["taste"] = taste };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"taste.json: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentSaveTaste(Session session, JObject taste)
+ {
+ await Task.CompletedTask;
+ if (taste is null)
+ {
+ return new JObject { ["error"] = "taste required" };
+ }
+ if (taste["updated"] == null)
+ {
+ taste["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ }
+ string dir = Path.Combine(DataRoot(), "Assistent");
+ Directory.CreateDirectory(dir);
+ string path = TasteJsonPath();
+ File.WriteAllText(path, taste.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
+ return new JObject { ["success"] = true, ["path"] = path };
+ }
+
+ public async Task AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0, bool fetch = false)
+ {
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
JObject civitai = null;
JArray exampleUrls = [];
+ JArray previewUrls = [];
+ bool hasSidecar = false;
+ string fetchError = null;
+ bool fetched = false;
+
if (!string.IsNullOrWhiteSpace(weight))
{
string stem = Path.GetFileNameWithoutExtension(weight);
@@ -591,6 +646,7 @@ public class SwarmAssistentExtension : Extension
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
if (File.Exists(side))
{
+ hasSidecar = true;
try
{
civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8));
@@ -600,43 +656,113 @@ public class SwarmAssistentExtension : Extension
// ignore
}
}
+ foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
+ {
+ string prev = Path.Combine(dir ?? "", stem + suffix);
+ if (File.Exists(prev))
+ {
+ // Swarm View path — relative URL works in the same origin browser session.
+ previewUrls.Add($"View/Models/{(kind == "lora" ? "Lora" : "Stable-Diffusion")}/{Path.GetFileName(prev)}");
+ break;
+ }
+ }
}
+
if (civitai is not null)
{
if (version_id <= 0)
{
version_id = civitai["id"]?.Value() ?? 0;
}
- if (civitai["images"] is JArray imgs)
- {
- foreach (JToken img in imgs.Take(3))
- {
- string u = img?["url"]?.ToString();
- if (!string.IsNullOrWhiteSpace(u))
- {
- exampleUrls.Add(u);
- }
- }
- }
- if (civitai["trainedWords"] is null && civitai["model"] is JObject)
- {
- // keep as-is
- }
+ CollectExampleUrls(civitai, exampleUrls);
}
- JObject card = ReadCardObject(kind, name);
+
+ string hash = null;
string trigger = null;
try
{
if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h)
- && h.Models.TryGetValue(name, out T2IModel m))
+ && (h.Models.TryGetValue(name, out T2IModel m)
+ || h.Models.TryGetValue(name.Replace('\\', '/'), out m)))
{
trigger = m.Metadata?.TriggerPhrase;
+ hash = m.Metadata?.Hash;
}
}
catch
{
// ignore
}
+
+ if (fetch && civitai is null)
+ {
+ string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ fetchError = "Civitai: нет ключа в User Settings";
+ }
+ else
+ {
+ try
+ {
+ JObject remote = null;
+ if (version_id > 0)
+ {
+ remote = await FetchCivitaiModelVersion(apiKey, version_id);
+ }
+ if (remote is null && !string.IsNullOrWhiteSpace(hash))
+ {
+ string sha = hash.Trim().ToLowerInvariant();
+ if (sha.StartsWith("sha256:"))
+ {
+ sha = sha["sha256:".Length..];
+ }
+ if (sha.Length == 64)
+ {
+ remote = await FetchCivitaiByHash(apiKey, sha);
+ }
+ else
+ {
+ fetchError ??= "Civitai: хеш модели не SHA256";
+ }
+ }
+ if (remote is not null)
+ {
+ civitai = remote;
+ fetched = true;
+ version_id = remote["id"]?.Value() ?? version_id;
+ CollectExampleUrls(remote, exampleUrls);
+ if (!string.IsNullOrWhiteSpace(weight))
+ {
+ try
+ {
+ string stem = Path.GetFileNameWithoutExtension(weight);
+ string dir = Path.GetDirectoryName(weight);
+ string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
+ File.WriteAllText(side, remote.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
+ hasSidecar = true;
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentGetCardMeta write sidecar: {ex.Message}");
+ }
+ }
+ }
+ else if (fetchError is null)
+ {
+ fetchError = string.IsNullOrWhiteSpace(hash)
+ ? "Civitai: нет hash и version_id"
+ : "Хеш не найден на Civitai";
+ }
+ }
+ catch (Exception ex)
+ {
+ fetchError = $"Civitai: {ex.Message}";
+ }
+ }
+ }
+
+ JObject card = ReadCardObject(kind, name);
return new JObject
{
["success"] = true,
@@ -645,13 +771,124 @@ public class SwarmAssistentExtension : Extension
["version_id"] = version_id,
["trigger_phrase"] = trigger,
["has_card"] = card is not null,
+ ["has_sidecar"] = hasSidecar,
+ ["fetched"] = fetched,
+ ["fetch_error"] = fetchError,
["card"] = card,
["civitai"] = civitai,
["example_urls"] = exampleUrls,
+ ["preview_urls"] = previewUrls,
["weight_path"] = weight,
+ ["hash"] = hash,
};
}
+ static void CollectExampleUrls(JObject civitai, JArray exampleUrls)
+ {
+ if (civitai?["images"] is not JArray imgs)
+ {
+ return;
+ }
+ foreach (JToken img in imgs.Take(6))
+ {
+ string u = img?["url"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(u))
+ {
+ exampleUrls.Add(u);
+ }
+ }
+ }
+
+ async Task FetchCivitaiByHash(string apiKey, string sha)
+ {
+ string[] hosts = ["civitai.red", "civitai.com"];
+ Exception last = null;
+ foreach (string host in hosts)
+ {
+ try
+ {
+ string url = $"https://{host}/api/v1/model-versions/by-hash/{sha}";
+ using HttpRequestMessage req = new(HttpMethod.Get, url);
+ if (!string.IsNullOrWhiteSpace(apiKey))
+ {
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
+ }
+ using HttpResponseMessage resp = await HttpClient.SendAsync(req);
+ string body = await resp.Content.ReadAsStringAsync();
+ if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ continue;
+ }
+ if (!resp.IsSuccessStatusCode)
+ {
+ last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
+ if ((int)resp.StatusCode is 401 or 403)
+ {
+ throw last;
+ }
+ continue;
+ }
+ return JObject.Parse(body);
+ }
+ catch (Exception ex) when (ex is not HttpRequestException && ex.Message.Contains("401"))
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ last = ex;
+ }
+ }
+ if (last is not null)
+ {
+ throw last;
+ }
+ return null;
+ }
+
+ async Task FetchCivitaiModelVersion(string apiKey, int versionId)
+ {
+ string[] hosts = ["civitai.red", "civitai.com"];
+ Exception last = null;
+ foreach (string host in hosts)
+ {
+ try
+ {
+ string url = $"https://{host}/api/v1/model-versions/{versionId}";
+ using HttpRequestMessage req = new(HttpMethod.Get, url);
+ if (!string.IsNullOrWhiteSpace(apiKey))
+ {
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
+ }
+ using HttpResponseMessage resp = await HttpClient.SendAsync(req);
+ string body = await resp.Content.ReadAsStringAsync();
+ if (!resp.IsSuccessStatusCode)
+ {
+ last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
+ if ((int)resp.StatusCode is 401 or 403)
+ {
+ throw last;
+ }
+ continue;
+ }
+ return JObject.Parse(body);
+ }
+ catch (Exception ex)
+ {
+ last = ex;
+ if (ex.Message.Contains("401") || ex.Message.Contains("403"))
+ {
+ throw;
+ }
+ }
+ }
+ if (last is not null)
+ {
+ throw last;
+ }
+ return null;
+ }
+
/// Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).
public async Task AssistentListInventory(Session session, bool rescan = false)
@@ -810,6 +1047,27 @@ public class SwarmAssistentExtension : Extension
["has_card"] = hasCard,
["krea_likely"] = LooksLikeKreaArch(model),
};
+ if (!string.IsNullOrWhiteSpace(weight))
+ {
+ string stem = Path.GetFileNameWithoutExtension(weight);
+ string dir = Path.GetDirectoryName(weight);
+ string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
+ entry["has_sidecar"] = File.Exists(side);
+ foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
+ {
+ string prev = Path.Combine(dir ?? "", stem + suffix);
+ if (File.Exists(prev))
+ {
+ string folder = kind == "lora" ? "Lora" : "Stable-Diffusion";
+ entry["preview_url"] = $"View/Models/{folder}/{Path.GetFileName(prev)}";
+ break;
+ }
+ }
+ }
+ else
+ {
+ entry["has_sidecar"] = false;
+ }
if (triggers is not null && triggers.Count > 0)
{
entry["triggers"] = triggers;
@@ -1181,21 +1439,7 @@ public class SwarmAssistentExtension : Extension
{
return false;
}
- if (!string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
- {
- return true;
- }
- if (patch["actions"] is JArray acts)
- {
- foreach (JToken a in acts)
- {
- if (string.Equals(a?.ToString(), "search_civitai", StringComparison.OrdinalIgnoreCase))
- {
- return true;
- }
- }
- }
- return false;
+ return !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch));
}
async Task<(string reply, JObject raw, JArray civitaiResults)> RunChatWithHops(
@@ -1228,10 +1472,6 @@ public class SwarmAssistentExtension : Extension
}
string query = ExtractSearchQuery(patch);
if (string.IsNullOrWhiteSpace(query))
- {
- query = userMessages.LastOrDefault(m => m["role"]?.ToString() == "user")?["content"]?.ToString() ?? "";
- }
- if (string.IsNullOrWhiteSpace(query))
{
break;
}
diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html
index 6b948c1..2e07792 100644
--- a/Tabs/Text2Image/Assistent.html
+++ b/Tabs/Text2Image/Assistent.html
@@ -1,93 +1,106 @@
-
Swarm Assistent is for Krea 2 models only. Select a Krea 2 checkpoint to enable the chat.
+
Swarm Assistent только для моделей Krea 2. Выбери checkpoint Krea 2, чтобы включить чат.