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
Напиши, что сгенерировать — или кинь референс и попроси правку.`; @@ -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 @@
- +
Assistent
- - + +
- - + + + + - - + + - + - +
-
Collaborative Krea 2
-
Write a prompt, drop refs, pick a persona, or open Cards for LoRA tips.
+
Совместная работа с Krea 2
+
Напиши промпт, кинь refs, выбери персону или открой Карточки для LoRA.
- @@ -98,25 +111,36 @@
- +
- Select a model + Выбери модель
- + +
+ + + + + + + + + +
- - - - + + + +