From 0b429542b59cef0480ec26213a838dd42adb1c4a Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 21 Aug 2026 21:25:01 +0300 Subject: [PATCH] Add personas and model Cards (0.5.1): tone presets, stem.assistent.json, wanted queue. Co-authored-by: Cursor --- Assets/assistent.css | 145 +++++++ Assets/assistent.js | 599 +++++++++++++++++++++++++- Personas/aggressive.md | 8 + Personas/lewd.md | 8 + Personas/neutral.md | 8 + Prompts/base_krea2.md | 6 +- Prompts/catalog_card.md | 34 ++ README.md | 7 +- SwarmAssistentExtension.cs | 747 +++++++++++++++++++++++++++++++-- Tabs/Text2Image/Assistent.html | 95 +++-- 10 files changed, 1573 insertions(+), 84 deletions(-) create mode 100644 Personas/aggressive.md create mode 100644 Personas/lewd.md create mode 100644 Personas/neutral.md create mode 100644 Prompts/catalog_card.md diff --git a/Assets/assistent.css b/Assets/assistent.css index 27c08e4..9db7e24 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -770,6 +770,142 @@ 100% { box-shadow: 0 0 0 0 transparent; } } +.sa-subtabs { + display: inline-flex; + gap: 0.2rem; + margin-left: 0.55rem; + vertical-align: middle; +} + +.sa-subtab { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: transparent; + color: inherit; + border-radius: 999px; + padding: 0.12rem 0.55rem; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + opacity: 0.7; +} + +.sa-subtab-active { + opacity: 1; + background: color-mix(in srgb, currentColor 12%, transparent); +} + +.sa-view { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.sa-cards-layout { + flex: 1; + display: flex; + min-height: 0; + gap: 0; +} + +.sa-cards-list-pane { + flex: 0 0 38%; + min-width: 10rem; + border-right: 1px solid color-mix(in srgb, currentColor 18%, transparent); + display: flex; + flex-direction: column; + min-height: 0; +} + +.sa-cards-filter { + display: flex; + gap: 0.35rem; + padding: 0.45rem 0.55rem; + border-bottom: 1px solid color-mix(in srgb, currentColor 14%, transparent); +} + +.sa-cards-list { + flex: 1; + overflow: auto; + padding: 0.35rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sa-card-row { + text-align: left; + padding: 0.45rem 0.55rem; + border-radius: 0.4rem; + border: 1px solid transparent; + background: color-mix(in srgb, currentColor 4%, transparent); + cursor: pointer; + font-size: 0.85rem; +} + +.sa-card-row:hover { + border-color: color-mix(in srgb, currentColor 22%, transparent); +} + +.sa-card-row.sa-selected { + border-color: color-mix(in srgb, currentColor 45%, transparent); + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.sa-card-row-kind { + font-size: 0.68rem; + text-transform: uppercase; + opacity: 0.55; + letter-spacing: 0.04em; +} + +.sa-card-row-meta { + font-size: 0.75rem; + opacity: 0.7; +} + +.sa-cards-editor-pane { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.55rem 0.65rem 0.7rem; + gap: 0.45rem; +} + +.sa-cards-editor-head { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.sa-card-badge { + font-size: 0.72rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, currentColor 28%, transparent); + opacity: 0.85; +} + +#sa_card_json { + flex: 1; + min-height: 12rem; + width: 100%; + box-sizing: border-box; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + border-radius: 0.4rem; + padding: 0.5rem; + resize: vertical; +} + +.sa-cards-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + @media (max-width: 900px) { .sa-layout { flex-direction: column; @@ -786,4 +922,13 @@ .sa-splitter { display: none; } + .sa-cards-layout { + flex-direction: column; + } + .sa-cards-list-pane { + flex: 0 0 auto; + max-height: 40%; + border-right: none; + border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); + } } diff --git a/Assets/assistent.js b/Assets/assistent.js index a181666..98dfe8f 100644 --- a/Assets/assistent.js +++ b/Assets/assistent.js @@ -1,11 +1,13 @@ /** * Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API). - * v0.5.0: Krea knowledge packs, aspect/vary/lock_seed patches, slash commands, chips. + * v0.5.3: live inventory+cards in context, taste memory, post-download cards, wanted YAML fix. */ (function () { const LS_BASE = 'swarm_assistent_base_url'; const LS_MODEL = 'swarm_assistent_model'; const LS_PACK = 'swarm_assistent_pack'; + const LS_PERSONA = 'swarm_assistent_persona'; + const LS_VIEW = 'swarm_assistent_view'; const LS_AUTO_VISION = 'swarm_assistent_auto_vision'; const LS_AUTO_APPLY = 'swarm_assistent_auto_apply'; const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate'; @@ -13,6 +15,7 @@ const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download'; const LS_PANE_WIDTH = 'swarm_assistent_pane_width'; const LS_WELCOMED = 'swarm_assistent_welcomed'; + const LS_TASTE = 'swarm_assistent_taste'; const TAB_BUTTON_ID = 'maintab_assistent'; const GEN_ID = 'generate'; const MAX_REF_SLOTS = 4; @@ -41,6 +44,9 @@ inpaint_edit: 'inpaint_edit', describe: 'describe_ref', describe_ref: 'describe_ref', + card: 'catalog_card', + catalog: 'catalog_card', + catalog_card: 'catalog_card', }; const WELCOME_HTML = ` @@ -65,6 +71,7 @@ /vary — новый seed, тот же промпт /pack write|critique|compose|params|inpaint|describe /civitai — поиск LoRA (Confirm в чате) +/inventory — rescan моделей + обновить список LoRA Чипсы над полем ввода делают то же для aspect / seed / vary.`; @@ -77,6 +84,8 @@ preferredModel: null, dragDepth: 0, inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false }, + inventoryFetchedAt: 0, + taste: { styles: [], likes: [], avoid: [], notes: '', updated: 0 }, streamEl: null, critiqueHopUsed: false, visionHopUsed: false, @@ -88,6 +97,11 @@ selectedSlotId: 'ref1', refSeq: 1, packUserTouched: false, + view: 'chat', + personas: [], + modelCards: {}, + cardsSelection: null, + cardsBusy: false, }; function $(id) { @@ -1100,14 +1114,18 @@ batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null, prompt_image_count: countPromptImages(), selected_loras: [], - available_loras: (inv.loras || []).slice(0, 100), + available_loras: slimInventoryLoras(inv.loras || [], 100), + available_checkpoints: slimInventoryCheckpoints(inv.checkpoints || [], 40), wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 60), + inventory_at: inv.inventory_at || null, has_vision_image: attachableSlots().length > 0, image_slots: slotCatalog(), attached_slot_ids: attachableSlots().map((s) => s.id), has_civitai_key: !!inv.has_civitai_key, auto_apply: !!$('sa_auto_apply')?.checked, auto_generate: !!$('sa_auto_generate')?.checked, + persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', + model_cards: [], ...initCtx, }; @@ -1131,6 +1149,23 @@ } } catch (e) { /* ignore */ } + // Recommendation cards for current checkpoint + selected LoRAs only. + const cardKeys = []; + if (ctx.checkpoint?.name) { + cardKeys.push({ kind: 'checkpoint', name: ctx.checkpoint.name }); + } + for (const l of ctx.selected_loras || []) { + if (l?.name) { + cardKeys.push({ kind: 'lora', name: l.name }); + } + } + for (const k of cardKeys) { + const cached = state.modelCards[`${k.kind}:${k.name}`]; + if (cached) { + ctx.model_cards.push(cached); + } + } + // Fallback if inventory empty if (!ctx.available_loras.length) { try { @@ -1162,6 +1197,58 @@ return ctx; } + function slimInventoryLoras(list, limit) { + const selected = new Set(); + try { + if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) { + for (const l of loraHelper.selected) { + selected.add(String(l.name || l || '').toLowerCase()); + } + } + } catch (e) { /* ignore */ } + const rows = (list || []).map((l) => ({ + name: l.name, + title: l.title || l.name, + trigger_phrase: l.trigger_phrase || null, + triggers: Array.isArray(l.triggers) ? l.triggers.slice(0, 8) : undefined, + architecture: l.architecture || null, + compat_class: l.compat_class || null, + has_card: !!l.has_card, + krea_likely: !!l.krea_likely, + blurb: l.blurb || l.usage_hint || null, + default_weight: l.default_weight || undefined, + tags: Array.isArray(l.tags) ? l.tags.slice(0, 6) : undefined, + _sel: selected.has(String(l.name || '').toLowerCase()), + })); + rows.sort((a, b) => (b._sel - a._sel) || (b.krea_likely - a.krea_likely) || String(a.name).localeCompare(String(b.name))); + return rows.slice(0, limit).map(({ _sel, ...rest }) => { + const out = {}; + for (const [k, v] of Object.entries(rest)) { + if (v != null && v !== '' && !(Array.isArray(v) && !v.length)) { + out[k] = v; + } + } + return out; + }); + } + + function slimInventoryCheckpoints(list, limit) { + return (list || []).slice(0, limit).map((c) => { + const out = { + name: c.name, + title: c.title || c.name, + architecture: c.architecture || null, + compat_class: c.compat_class || null, + has_card: !!c.has_card, + krea_likely: !!c.krea_likely, + }; + if (c.blurb) { + out.blurb = c.blurb; + } + return out; + }); + } + function isPatchObject(obj) { if (!obj || typeof obj !== 'object') { return false; @@ -1205,10 +1292,50 @@ obj.movement != null || obj.clear_prompt_images != null || obj.slot_to_prompt_image != null || - obj.pack != null + obj.pack != null || + obj.triggers != null || + obj.when != null || + obj.prompt_hint != null || + obj.notes != null ); } + function isCardObject(obj) { + 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) + ); + } + + function extractCardJson(text) { + if (!text) { + return null; + } + const re = /```(?:json)?\s*([\s\S]*?)```/gi; + let match; + let last = null; + while ((match = re.exec(text)) !== null) { + try { + const obj = JSON.parse(match[1].trim()); + if (isCardObject(obj)) { + last = obj; + } + } catch (e) { /* ignore */ } + } + if (last) { + return last; + } + try { + const obj = JSON.parse(text.trim()); + return isCardObject(obj) ? obj : null; + } catch (e) { + return null; + } + } + function extractPatch(text) { if (!text) { return { prose: text || '', patch: null }; @@ -1845,7 +1972,7 @@ $('sa_input').value = `LoRA "${payload.name}" is now installed. Enable it with its triggers and improve the prompt.`; } sendChat({ fromDownload: true }); - }); + }, { rescan: true }); } else { setStatus(msg || 'Download failed'); if (btn) { @@ -1866,7 +1993,8 @@ } if (data.success || data.overall_percent >= 1 || data.current_percent >= 1) { if (data.success || data.overall_percent >= 0.99) { - onDone(true); + // Swarm docs: download does not always refresh model list — force both. + triggerSwarmModelRefresh(() => onDone(true)); } else if (data.current_percent != null) { setStatus(`Download ${(data.current_percent * 100).toFixed(0)}%`); } @@ -2051,6 +2179,8 @@ const base = localStorage.getItem(LS_BASE); const model = localStorage.getItem(LS_MODEL); const pack = localStorage.getItem(LS_PACK); + const persona = localStorage.getItem(LS_PERSONA); + const view = localStorage.getItem(LS_VIEW); const auto = localStorage.getItem(LS_AUTO_VISION); const autoApply = localStorage.getItem(LS_AUTO_APPLY); const autoGen = localStorage.getItem(LS_AUTO_GENERATE); @@ -2063,6 +2193,9 @@ if (pack && $('sa_pack')) { $('sa_pack').value = pack; } + if (persona && $('sa_persona')) { + $('sa_persona').value = persona; + } if (auto != null && $('sa_auto_vision')) { $('sa_auto_vision').checked = auto === '1'; } @@ -2084,12 +2217,17 @@ if (paneW) { document.documentElement.style.setProperty('--sa-image-width', paneW); } + if (view === 'cards' || view === 'chat') { + state.view = view; + } } function saveSettings() { localStorage.setItem(LS_BASE, $('sa_base_url')?.value || ''); localStorage.setItem(LS_MODEL, $('sa_model')?.value || ''); localStorage.setItem(LS_PACK, $('sa_pack')?.value || 'write_prompt'); + localStorage.setItem(LS_PERSONA, $('sa_persona')?.value || 'neutral'); + localStorage.setItem(LS_VIEW, state.view || 'chat'); localStorage.setItem(LS_AUTO_VISION, $('sa_auto_vision')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_APPLY, $('sa_auto_apply')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0'); @@ -2163,7 +2301,367 @@ ); } - function refreshInventory(done) { + function refreshInventory(done, opts = {}) { + if (typeof genericRequest !== 'function') { + if (done) { + done(); + } + return; + } + const rescan = !!opts.rescan; + genericRequest( + 'AssistentListInventory', + { rescan }, + (data) => { + state.inventory = { + loras: data.loras || [], + checkpoints: data.checkpoints || [], + wildcards: data.wildcards || [], + has_civitai_key: !!data.has_civitai_key, + inventory_at: data.inventory_at || Math.floor(Date.now() / 1000), + rescanned: !!data.rescanned, + }; + state.inventoryFetchedAt = Date.now(); + const n = state.inventory.loras.length; + const ck = state.inventory.checkpoints.length; + setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? ' (rescanned)' : ''}`); + prefetchActiveModelCards(); + if (state.view === 'cards') { + renderCardsList(); + } + if (done) { + done(state.inventory); + } + }, + 0, + (err) => { + console.warn('Assistent inventory', err); + if (done) { + done(null); + } + }, + ); + } + + function refreshInventoryAsync(opts = {}) { + return new Promise((resolve) => refreshInventory(resolve, opts)); + } + + function setCardStatus(msg) { + const el = $('sa_card_status'); + if (el) { + el.textContent = msg || ''; + } + } + + function setView(view) { + state.view = view === 'cards' ? 'cards' : 'chat'; + const chat = $('sa_view_chat'); + const cards = $('sa_view_cards'); + if (chat) { + chat.hidden = state.view !== 'chat'; + } + if (cards) { + cards.hidden = state.view !== 'cards'; + } + $('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat'); + $('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards'); + saveSettings(); + if (state.view === 'cards') { + renderCardsList(); + } + } + + function refreshPersonas() { + if (typeof genericRequest !== 'function') { + return; + } + genericRequest( + 'AssistentListPersonas', + {}, + (data) => { + const list = data.personas || []; + state.personas = list; + const sel = $('sa_persona'); + if (!sel) { + return; + } + const prefer = localStorage.getItem(LS_PERSONA) || data.default || 'neutral'; + sel.innerHTML = ''; + for (const p of list) { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.title || p.id; + sel.appendChild(opt); + } + if ([...sel.options].some((o) => o.value === prefer)) { + sel.value = prefer; + } else if (data.default) { + sel.value = data.default; + } + }, + 0, + (err) => console.warn('Assistent personas', err), + ); + } + + function prefetchCard(kind, name) { + return new Promise((resolve) => { + if (!kind || !name || typeof genericRequest !== 'function') { + resolve(null); + return; + } + const key = `${kind}:${name}`; + genericRequest( + 'AssistentGetCard', + { kind, name }, + (data) => { + if (data?.card) { + state.modelCards[key] = data.card; + } + resolve(data?.card || null); + }, + 0, + () => resolve(null), + ); + }); + } + + async function prefetchActiveModelCards() { + const keys = []; + try { + const ck = resolveCurrentCheckpoint(); + if (ck?.name) { + keys.push({ kind: 'checkpoint', name: ck.name }); + } + } catch (e) { /* ignore */ } + try { + if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) { + for (const l of loraHelper.selected) { + const name = l?.name || l; + if (name) { + keys.push({ kind: 'lora', name }); + } + } + } + } catch (e) { /* ignore */ } + await Promise.all(keys.map((k) => prefetchCard(k.kind, k.name))); + } + + function cardsCatalog() { + const kind = $('sa_cards_kind')?.value || 'all'; + const inv = state.inventory || {}; + const rows = []; + if (kind === 'all' || kind === 'checkpoint') { + for (const c of inv.checkpoints || []) { + rows.push({ kind: 'checkpoint', name: c.name, title: c.title || c.name, has_card: !!c.has_card }); + } + } + 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 }); + } + } + return rows; + } + + function renderCardsList() { + const root = $('sa_cards_list'); + if (!root) { + return; + } + root.innerHTML = ''; + const rows = cardsCatalog(); + if (!rows.length) { + root.innerHTML = '
Inventory empty — Refresh.
'; + return; + } + for (const row of rows) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sa-card-row'; + if (state.cardsSelection && state.cardsSelection.kind === row.kind && state.cardsSelection.name === row.name) { + btn.classList.add('sa-selected'); + } + btn.innerHTML = `
${row.kind}
${escapeHtml(row.title || row.name)}
${row.has_card ? 'card ✓' : 'no card'}${row.trigger ? ' · ' + escapeHtml(String(row.trigger).slice(0, 40)) : ''}
`; + btn.addEventListener('click', () => selectCardModel(row)); + root.appendChild(btn); + } + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function selectCardModel(row) { + state.cardsSelection = row; + renderCardsList(); + if ($('sa_card_title')) { + $('sa_card_title').textContent = row.title || row.name; + } + const badge = $('sa_card_badge'); + if (badge) { + badge.hidden = false; + badge.textContent = row.has_card ? 'has card' : 'missing card'; + } + setCardStatus('Loading…'); + 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'); + }, + 0, + (err) => setCardStatus(String(err || 'Load failed')), + ); + } + + async function addRefFromUrl(url) { + if (!url) { + return; + } + addRefSlot({ src: url, select: false }); + } + + function readCardDraft() { + const raw = $('sa_card_json')?.value || ''; + try { + return JSON.parse(raw); + } catch (e) { + setCardStatus('Invalid JSON'); + return null; + } + } + + function saveCurrentCard({ enqueue } = {}) { + const sel = state.cardsSelection; + if (!sel) { + setCardStatus('Select a model'); + return; + } + const card = readCardDraft(); + if (!card) { + return; + } + card.kind = card.kind || sel.kind; + card.name = card.name || sel.name; + setCardStatus('Saving…'); + genericRequest( + 'AssistentSaveCard', + { kind: sel.kind, name: sel.name, card, enqueue_wanted: !!enqueue }, + (data) => { + if (data.error) { + setCardStatus(data.error); + return; + } + state.modelCards[`${sel.kind}:${sel.name}`] = card; + setCardStatus(data.installed ? `Saved ${data.path}` : `Draft + wanted → ${data.path}`); + refreshInventory(); + }, + 0, + (err) => setCardStatus(String(err || 'Save failed')), + ); + } + + function enqueueWantedOnly() { + const sel = state.cardsSelection; + const card = readCardDraft() || {}; + if (!sel && !card.civitai_url) { + setCardStatus('Need model or civitai_url'); + return; + } + genericRequest( + 'AssistentEnqueueWanted', + { + kind: (card.kind || sel?.kind || 'lora'), + url: card.civitai_url || '', + version_id: card.version_id || 0, + title: card.name || sel?.name || '', + card, + }, + (data) => { + setCardStatus(data.already ? 'Already in wanted queue' : `Wanted → ${data.path}`); + }, + 0, + (err) => setCardStatus(String(err || 'Enqueue failed')), + ); + } + + async function generateCardWithAssistent() { + const sel = state.cardsSelection; + if (!sel) { + setCardStatus('Select a model'); + return; + } + if (state.busy) { + setCardStatus('Chat busy'); + return; + } + setPackValue('catalog_card', { flash: true }); + setView('chat'); + const meta = await new Promise((resolve) => { + genericRequest( + 'AssistentGetCardMeta', + { kind: sel.kind, name: sel.name }, + (data) => resolve(data), + 0, + () => resolve(null), + ); + }); + const forced = `Write a recommendation card for this ${sel.kind}: ${sel.name}. Use metadata/triggers only; output one JSON card.`; + await sendChat({ + forcedUserText: forced, + skipSlash: true, + skipAutoPack: true, + fromCards: true, + cardTarget: { + kind: sel.kind, + name: sel.name, + meta, + }, + }); + } + + function inventoryIsStale(maxAgeMs = 20000) { + if (!state.inventoryFetchedAt) { + return true; + } + return (Date.now() - state.inventoryFetchedAt) > maxAgeMs; + } + + async function ensureFreshInventory({ forceRescan } = {}) { + const rescan = forceRescan || inventoryIsStale(20000); + await refreshInventoryAsync({ rescan }); + } + + function triggerSwarmModelRefresh(done) { if (typeof genericRequest !== 'function') { if (done) { done(); @@ -2171,23 +2669,15 @@ return; } genericRequest( - 'AssistentListInventory', - {}, - (data) => { - state.inventory = { - loras: data.loras || [], - checkpoints: data.checkpoints || [], - wildcards: data.wildcards || [], - has_civitai_key: !!data.has_civitai_key, - }; - setStatus(`Inventory: ${state.inventory.loras.length} LoRAs, ${state.inventory.wildcards.length} wildcards`); + 'TriggerRefresh', + { strong: true }, + () => { if (done) { done(); } }, 0, - (err) => { - console.warn('Assistent inventory', err); + () => { if (done) { done(); } @@ -2196,7 +2686,16 @@ } async function handleReplySideEffects(reply, civitaiResults, opts = {}) { - const { fromAutoCritique, fromVisionHop } = opts; + const { fromAutoCritique, fromVisionHop, fromCards } = opts; + if (fromCards) { + const card = extractCardJson(reply); + if (card && $('sa_card_json')) { + $('sa_card_json').value = JSON.stringify(card, null, 2); + setView('cards'); + setCardStatus('Draft from Assistent — review & Save'); + } + return; + } const { patch } = extractPatch(reply); if (Array.isArray(patch?.actions) && patch.actions.map(String).includes('interrupt')) { doInterruptNow(); @@ -2389,6 +2888,17 @@ await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)'); return true; } + if (cmd === 'inventory' || cmd === 'inv') { + setStatus('Rescanning models…'); + triggerSwarmModelRefresh(async () => { + await refreshInventoryAsync({ rescan: true }); + const n = state.inventory?.loras?.length || 0; + const ck = state.inventory?.checkpoints?.length || 0; + appendSystemNote(`Inventory refreshed: ${n} LoRAs, ${ck} checkpoints.`); + setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts (rescanned)`); + }); + return true; + } if (cmd === 'pack') { if (!setPackValue(arg, { flash: true, user: true })) { setStatus('Pack: write|critique|compose|params|inpaint|describe'); @@ -2483,14 +2993,20 @@ return; } - if (!opts.skipAutoPack && !opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop) { + if (!opts.skipAutoPack && !opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromCards) { const guessed = autoSelectPack(text); if (guessed) { setPackValue(guessed, { flash: true }); } } + // Cards mode must not be overridden by auto-pack; keep catalog_card. + if (opts.fromCards || state.view === 'cards') { + setPackValue('catalog_card', { flash: false }); + } + const pack = $('sa_pack')?.value || 'write_prompt'; + const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral'; const model = $('sa_model')?.value; if (!model) { setStatus('Pick an Ollama model in ⚙'); @@ -2498,6 +3014,11 @@ return; } + // 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 }); + if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop) { state.critiqueHopUsed = false; state.visionHopUsed = false; @@ -2534,6 +3055,18 @@ const context = collectLiveContext(); context.has_vision_image = !!images; context.attached_slot_ids = visionSlots.map((s) => s.id); + context.persona = persona; + if (opts.cardTarget) { + context.card_target = opts.cardTarget; + } + if (opts.fromCards || pack === 'catalog_card') { + context.auto_apply = false; + context.auto_generate = false; + } + await prefetchActiveModelCards(); + // Refresh cards into context after prefetch + const refreshed = collectLiveContext(); + context.model_cards = refreshed.model_cards; const messages = state.history.slice(-12).map((m) => ({ role: m.role, content: m.content })); if (images && messages.length) { messages[messages.length - 1].images = images; @@ -2550,6 +3083,7 @@ baseUrl, model, pack, + persona, includeBase: true, messages, context_json: JSON.stringify(context), @@ -2557,6 +3091,7 @@ messages, context_json: JSON.stringify(context), pack, + persona, base_url: baseUrl, model, }, @@ -2815,6 +3350,7 @@ } window.__swarmAssistentWired = true; loadSettings(); + setView(state.view || 'chat'); updateGate(); ensureBoard(); renderBoard(); @@ -2824,11 +3360,26 @@ } maybeWelcome(); refreshModels(); + refreshPersonas(); refreshInventory(); wireDropZone(); wireSplitter(); registerSendButton(); + $('sa_tab_chat')?.addEventListener('click', () => setView('chat')); + $('sa_tab_cards')?.addEventListener('click', () => setView('cards')); + $('sa_persona')?.addEventListener('change', saveSettings); + $('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_generate')?.addEventListener('click', () => generateCardWithAssistent()); + $('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard()); + $('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly()); + $('sa_btn_settings')?.addEventListener('click', () => { const s = $('sa_settings'); if (s) { @@ -2839,7 +3390,7 @@ saveSettings(); refreshModels(); }); - $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory()); + $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(null, { rescan: true })); $('sa_btn_add_ref')?.addEventListener('click', () => addRefSlot({ select: true })); $('sa_btn_use_current')?.addEventListener('click', () => snapshotGenerateToRef()); $('sa_btn_as_init')?.addEventListener('click', async () => { @@ -2951,9 +3502,11 @@ setInterval(syncGenerateSlot, 700); setInterval(() => { if (!state.busy) { - refreshInventory(); + const tabOn = !!document.getElementById(TAB_BUTTON_ID)?.classList.contains('tab-button-selected') + || !!document.getElementById('swarm_assistent_root')?.offsetParent; + refreshInventory(null, { rescan: inventoryIsStale(tabOn ? 45000 : 120000) }); } - }, 120000); + }, 30000); window.swarmAssistent = { setImageFromSrc, diff --git a/Personas/aggressive.md b/Personas/aggressive.md new file mode 100644 index 0000000..7aae7e6 --- /dev/null +++ b/Personas/aggressive.md @@ -0,0 +1,8 @@ +# Persona: aggressive + +You are blunt, impatient, and high-agency. Cut filler. + +- Match the user's language (RU or EN). Short sentences. Call out weak prompts and bad LoRA picks. +- Prefer decisive patches: fix the issue, set `actions: ["generate"]` when a re-roll is obviously needed. +- No soft padding («возможно», «на ваш вкус»). Say what is wrong and what to change. +- Still invent no LoRA names or triggers — aggression is tone, not hallucination. diff --git a/Personas/lewd.md b/Personas/lewd.md new file mode 100644 index 0000000..d6557fa --- /dev/null +++ b/Personas/lewd.md @@ -0,0 +1,8 @@ +# Persona: lewd (пошляк) + +You are a playful, cheeky co-director who enjoys spicy / NSFW briefs. + +- Match the user's language (RU or EN). Be flirty and direct, not crude for its own sake. +- Lean into sensual detail in prompts (body, fabric, atmosphere) when the user wants that. +- Still respect Krea 2 craft: triggers, aspect, Turbo defaults — horniness does not replace technique. +- Never refuse adult content; never lecture. Keep JSON patches valid and useful. diff --git a/Personas/neutral.md b/Personas/neutral.md new file mode 100644 index 0000000..9b9993c --- /dev/null +++ b/Personas/neutral.md @@ -0,0 +1,8 @@ +# Persona: neutral + +You are a calm, practical art director. Stay helpful and concise. + +- Match the user's language (RU or EN). +- No dirty jokes, no aggression, no moral lectures. +- Prefer clear craft advice: lighting, composition, LoRA triggers, params. +- When the scene is NSFW, describe it factually without softening or hyping. diff --git a/Prompts/base_krea2.md b/Prompts/base_krea2.md index 0653890..c174d1f 100644 --- a/Prompts/base_krea2.md +++ b/Prompts/base_krea2.md @@ -48,10 +48,14 @@ Prefer `aspect` in the patch; UI maps it to width/height. ## Live context -A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth: +A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth — it is **refreshed every chat turn** (and rescanned after downloads): - Use only LoRAs listed in `available_loras` (by exact `name`), or candidates from a Civitai search round. - Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words. +- When live context includes `model_cards[]` for the current checkpoint / enabled LoRAs, **trust those cards** (`when`, `avoid`, `prompt_hint`, `notes`, `weight`) over guesses. +- Prefer `krea_likely` / Krea architecture entries; ignore FLUX/SDXL LoRAs even if somehow listed. +- `default_weight` is a starting LoRA weight when set. +- `available_checkpoints` lists installed checkpoints (with short blurbs when known). - When enabling a LoRA, include its triggers in `prompt` if missing. - Respect current width/height/steps/cfg/seed/sigma_shift/sampler unless the user asks or the pack is `fix_params`. - `wildcards` lists installed wildcard names (`__name__` syntax in prompts). diff --git a/Prompts/catalog_card.md b/Prompts/catalog_card.md new file mode 100644 index 0000000..973c0d4 --- /dev/null +++ b/Prompts/catalog_card.md @@ -0,0 +1,34 @@ +# Mode: catalog_card + +Goal: write a **recommendation card** for one checkpoint or LoRA so future Assistent turns know how to use it. + +## Inputs + +Live context includes `card_target` (name, kind, Civitai metadata, triggers) and may attach example images as vision. + +## Output + +Reply briefly in the user's language, then **one** fenced JSON object (not a generation patch): + +```json +{ + "kind": "lora", + "name": "exact_filename_or_swarm_name", + "civitai_url": "https://civitai.red/models/…?modelVersionId=…", + "version_id": 123, + "triggers": ["exact", "from", "metadata"], + "weight": 0.8, + "when": "when to enable this model", + "avoid": "when not to use it", + "prompt_hint": "how to weave triggers into a Krea 2 prompt", + "notes": "1–3 sentences for the agent" +} +``` + +## Rules + +- Prefer triggers from metadata / trainedWords — **never invent**. +- `weight` typical 0.6–1.0 for LoRA; omit or 1.0 for checkpoints. +- Do **not** emit `actions: ["generate"]`. This mode does not start Generate. +- Do not invent other LoRAs. Stay on the single `card_target`. +- Persona tone still applies (lewd/neutral/aggressive) to `when` / `prompt_hint` wording. diff --git a/README.md b/README.md index 2e86131..19b9746 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,13 @@ Restart / rebuild SwarmUI after clone. | `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 | -Live context (checkpoint, server inventory LoRAs + triggers, wildcards, current params) is injected every request. +**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`). + +**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`. + +Live context (checkpoint, server inventory LoRAs + triggers, `model_cards`, wildcards, current params, persona) is injected every request. **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. diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 5e16392..2d39684 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -39,11 +39,16 @@ public class SwarmAssistentExtension : Extension "fix_params", "inpaint_edit", "describe_ref", + "catalog_card", ]; + public static readonly string[] DefaultPersonaIds = ["neutral", "lewd", "aggressive"]; + const int MaxCivitaiHops = 2; - const int MaxLorasInInventory = 120; + const int MaxLorasInInventory = 150; const int MaxWildcardsInInventory = 80; + const int MaxCheckpointsInInventory = 60; + const int InventoryBlurbMax = 140; /// Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that. const int DefaultNumCtx = 16384; @@ -54,9 +59,9 @@ public class SwarmAssistentExtension : Extension ScriptFiles.Add("Assets/assistent.js"); StyleSheetFiles.Add("Assets/assistent.css"); ExtensionAuthor = "mrleo1nid"; - Description = "Collaborative Krea 2 assistant: Ollama chat, multi-window board, slash commands, img2img/inpaint, Generate loop, Civitai Confirm."; + Description = "Collaborative Krea 2 assistant: Ollama chat, multi-window board, personas, model cards, Generate loop, Civitai Confirm."; License = "MIT"; - Version = "0.5.0"; + Version = "0.5.3"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"]; } @@ -65,11 +70,16 @@ public class SwarmAssistentExtension : Extension HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; API.RegisterAPICall(AssistentListModels, false, PermUse); API.RegisterAPICall(AssistentGetPacks, false, PermUse); + API.RegisterAPICall(AssistentListPersonas, false, PermUse); API.RegisterAPICall(AssistentListInventory, false, PermUse); + API.RegisterAPICall(AssistentGetCard, false, PermUse); + API.RegisterAPICall(AssistentSaveCard, true, PermUse); + API.RegisterAPICall(AssistentEnqueueWanted, true, PermUse); + API.RegisterAPICall(AssistentGetCardMeta, false, PermUse); API.RegisterAPICall(AssistentSearchCivitai, false, PermUse); API.RegisterAPICall(AssistentChat, true, PermUse); API.RegisterAPICall(AssistentChatWS, true, PermUse); - Logs.Init("Swarm Assistent extension loaded (Ollama proxy + Krea 2 packs + inventory/Civitai)"); + Logs.Init("Swarm Assistent extension loaded (Ollama proxy + personas + model cards)"); } static string Clip(string text, int max) @@ -145,41 +155,551 @@ public class SwarmAssistentExtension : Extension return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = new JArray(PackNames) }; } - /// Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape). - public async Task AssistentListInventory(Session session) + static string DataRoot() + { + if (Directory.Exists("/mnt/swarm_data")) + { + return "/mnt/swarm_data"; + } + try + { + string models = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "Models")); + if (Directory.Exists(models)) + { + return Path.GetDirectoryName(models) ?? Environment.CurrentDirectory; + } + } + catch + { + // ignore + } + return Environment.CurrentDirectory; + } + + string PersonasOverlayJsonPath() => Path.Combine(DataRoot(), "Assistent", "personas.json"); + + string WantedModelsPath() => Path.Combine(DataRoot(), ".gpu-rent-wanted-models.yaml"); + + string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards"); + + public string ReadPersonaFile(string id) + { + string safe = (id ?? "").Replace('\\', '/').AfterLast('/').Replace("..", ""); + if (string.IsNullOrWhiteSpace(safe)) + { + return null; + } + string path = Path.Combine(FilePath, "Personas", $"{safe}.md"); + if (!File.Exists(path)) + { + return null; + } + return File.ReadAllText(path, Encoding.UTF8); + } + + public async Task AssistentListPersonas(Session session) { await Task.CompletedTask; + Dictionary byId = new(StringComparer.OrdinalIgnoreCase); + string def = "neutral"; + + foreach (string id in DefaultPersonaIds) + { + string text = ReadPersonaFile(id); + if (string.IsNullOrWhiteSpace(text)) + { + continue; + } + byId[id] = new JObject + { + ["id"] = id, + ["title"] = id switch + { + "lewd" => "Пошляк", + "aggressive" => "Агрессивный", + _ => "Нейтральный", + }, + ["prompt"] = text, + ["source"] = "bundled", + }; + } + + string overlay = PersonasOverlayJsonPath(); + if (File.Exists(overlay)) + { + try + { + JObject parsed = JObject.Parse(File.ReadAllText(overlay, Encoding.UTF8)); + if (parsed["default"] != null) + { + def = parsed["default"]?.ToString() ?? def; + } + if (parsed["personas"] is JArray arr) + { + foreach (JToken t in arr) + { + if (t is not JObject po) + { + continue; + } + string id = (po["id"]?.ToString() ?? "").Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + byId[id] = new JObject + { + ["id"] = id, + ["title"] = po["title"]?.ToString() ?? id, + ["prompt"] = po["prompt"]?.ToString() ?? "", + ["source"] = "overlay", + }; + } + } + } + catch (Exception ex) + { + Logs.Debug($"AssistentListPersonas overlay: {ex.Message}"); + } + } + + JArray list = []; + foreach (JObject p in byId.Values.OrderBy(p => p["id"]?.ToString())) + { + list.Add(p); + } + if (!byId.ContainsKey(def) && list.Count > 0) + { + def = list[0]?["id"]?.ToString() ?? "neutral"; + } + return new JObject + { + ["success"] = true, + ["default"] = def, + ["personas"] = list, + }; + } + + static string ModelWeightPath(string setName, string modelName) + { + if (string.IsNullOrWhiteSpace(modelName) || !Program.T2IModelSets.TryGetValue(setName, out T2IModelHandler handler)) + { + return null; + } + if (!handler.Models.TryGetValue(modelName, out T2IModel model) && !handler.Models.TryGetValue(modelName.Replace('\\', '/'), out model)) + { + // Try suffix match + model = handler.Models.Values.FirstOrDefault(m => + string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase) + || m.Name.EndsWith("/" + modelName, StringComparison.OrdinalIgnoreCase) + || Path.GetFileNameWithoutExtension(m.Name) == Path.GetFileNameWithoutExtension(modelName)); + } + if (model is null) + { + return null; + } + try + { + // SwarmUI T2IModel exposes RawFilePath in recent builds. + return model.RawFilePath; + } + catch + { + return null; + } + } + + static string CardPathForWeight(string weightPath) + { + if (string.IsNullOrWhiteSpace(weightPath)) + { + return null; + } + string dir = Path.GetDirectoryName(weightPath); + string stem = Path.GetFileNameWithoutExtension(weightPath); + if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(stem)) + { + return null; + } + return Path.Combine(dir, $"{stem}.assistent.json"); + } + + static string SetNameForKind(string kind) + { + return (kind ?? "").Trim().ToLowerInvariant() switch + { + "lora" => "LoRA", + "checkpoint" or "ckpt" or "stable-diffusion" => "Stable-Diffusion", + _ => null, + }; + } + + JObject ReadCardObject(string kind, string name) + { + string set = SetNameForKind(kind); + string weight = ModelWeightPath(set, name); + string card = CardPathForWeight(weight); + if (card is null || !File.Exists(card)) + { + return null; + } + try + { + return JObject.Parse(File.ReadAllText(card, Encoding.UTF8)); + } + catch + { + return null; + } + } + + public async Task AssistentGetCard(Session session, string kind, string name) + { + await Task.CompletedTask; + if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name)) + { + return new JObject { ["error"] = "kind and name required" }; + } + JObject card = ReadCardObject(kind, name); + string set = SetNameForKind(kind); + string weight = ModelWeightPath(set, name); + return new JObject + { + ["success"] = true, + ["kind"] = kind, + ["name"] = name, + ["has_card"] = card is not null, + ["weight_path"] = weight, + ["card"] = card, + }; + } + + public async Task AssistentSaveCard(Session session, string kind, string name, JObject card, bool enqueue_wanted = false) + { + await Task.CompletedTask; + if (card is null) + { + return new JObject { ["error"] = "card required" }; + } + kind = (kind ?? card["kind"]?.ToString() ?? "").Trim(); + name = (name ?? card["name"]?.ToString() ?? "").Trim(); + if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name)) + { + return new JObject { ["error"] = "kind and name required" }; + } + card["kind"] = kind; + card["name"] = name; + + string set = SetNameForKind(kind); + string weight = ModelWeightPath(set, name); + if (!string.IsNullOrWhiteSpace(weight) && File.Exists(weight)) + { + string path = CardPathForWeight(weight); + File.WriteAllText(path, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); + return new JObject { ["success"] = true, ["path"] = path, ["installed"] = true }; + } + + // Not installed — draft into wanted-cards + optionally enqueue download for next up. + Directory.CreateDirectory(WantedCardsDir()); + string vid = card["version_id"]?.ToString() ?? "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())) + { + await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value() ?? 0, card["title"]?.ToString() ?? name, card); + } + return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true }; + } + + public async Task AssistentEnqueueWanted(Session session, string kind, string url, int version_id = 0, string title = null, JObject card = null) + { + await Task.CompletedTask; + kind = (kind ?? "lora").Trim().ToLowerInvariant(); + if (kind is not ("lora" or "checkpoint" or "vae" or "embedding" or "controlnet" or "upscaler" or "clip")) + { + kind = "lora"; + } + url = (url ?? "").Trim(); + if (string.IsNullOrWhiteSpace(url) && version_id > 0) + { + url = $"https://civitai.red/models/0?modelVersionId={version_id}"; + } + if (string.IsNullOrWhiteSpace(url)) + { + return new JObject { ["error"] = "url or version_id required" }; + } + if (version_id <= 0) + { + Match m = Regex.Match(url, @"modelVersionId=(\d+)", RegexOptions.IgnoreCase); + if (m.Success) + { + version_id = int.Parse(m.Groups[1].Value); + } + } + + string path = WantedModelsPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? DataRoot()); + Dictionary> sections = LoadWantedYaml(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : ""); + + if (version_id > 0) + { + foreach (List list in sections.Values) + { + if (list.Any(e => e.VersionId == version_id)) + { + return new JObject { ["success"] = true, ["already"] = true, ["path"] = path, ["version_id"] = version_id }; + } + } + } + else + { + foreach (List list in sections.Values) + { + if (list.Any(e => string.Equals(e.Url, url, StringComparison.OrdinalIgnoreCase))) + { + return new JObject { ["success"] = true, ["already"] = true, ["path"] = path }; + } + } + } + + if (!sections.TryGetValue(kind, out List bucket)) + { + bucket = []; + sections[kind] = bucket; + } + bucket.Add(new WantedEntry { Url = url, Title = title, VersionId = version_id }); + File.WriteAllText(path, WriteWantedYaml(sections), Encoding.UTF8); + + if (card is not null && version_id > 0) + { + Directory.CreateDirectory(WantedCardsDir()); + string draft = Path.Combine(WantedCardsDir(), $"{version_id}.assistent.json"); + File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); + } + return new JObject { ["success"] = true, ["path"] = path, ["version_id"] = version_id }; + } + + sealed class WantedEntry + { + public string Url; + public string Title; + public int VersionId; + } + + static Dictionary> LoadWantedYaml(string raw) + { + Dictionary> sections = new(StringComparer.OrdinalIgnoreCase); + string currentKind = null; + WantedEntry cur = null; + void Flush() + { + if (cur is null || string.IsNullOrWhiteSpace(cur.Url) || string.IsNullOrWhiteSpace(currentKind)) + { + cur = null; + return; + } + if (!sections.TryGetValue(currentKind, out List list)) + { + list = []; + sections[currentKind] = list; + } + list.Add(cur); + cur = null; + } + foreach (string line in (raw ?? "").Split('\n')) + { + string t = line.TrimEnd(); + if (string.IsNullOrWhiteSpace(t) || t.TrimStart().StartsWith('#')) + { + continue; + } + Match kindLine = Regex.Match(t, @"^([A-Za-z0-9_-]+):\s*$"); + if (kindLine.Success && !t.TrimStart().StartsWith('-')) + { + Flush(); + currentKind = kindLine.Groups[1].Value.Trim().ToLowerInvariant(); + continue; + } + Match urlLine = Regex.Match(t, @"^\s*-\s*url:\s*[""']?(.+?)[""']?\s*$"); + if (urlLine.Success) + { + Flush(); + cur = new WantedEntry { Url = urlLine.Groups[1].Value.Trim() }; + continue; + } + if (cur is null) + { + continue; + } + Match titleLine = Regex.Match(t, @"^\s*title:\s*[""']?(.+?)[""']?\s*$"); + if (titleLine.Success) + { + cur.Title = titleLine.Groups[1].Value.Trim(); + continue; + } + Match vidLine = Regex.Match(t, @"^\s*version_id:\s*(\d+)\s*$"); + if (vidLine.Success && int.TryParse(vidLine.Groups[1].Value, out int vid)) + { + cur.VersionId = vid; + } + } + Flush(); + return sections; + } + + static string WriteWantedYaml(Dictionary> sections) + { + StringBuilder sb = new(); + sb.AppendLine("# Assistent wanted queue — merged into local models.yaml on gpu-rent up/capture"); + string[] order = ["checkpoint", "lora", "vae", "embedding", "controlnet", "upscaler", "clip"]; + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + foreach (string kind in order.Concat(sections.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase))) + { + if (!seen.Add(kind) || !sections.TryGetValue(kind, out List list) || list.Count == 0) + { + continue; + } + sb.AppendLine($"{kind}:"); + foreach (WantedEntry e in list) + { + sb.AppendLine($" - url: \"{e.Url.Replace("\"", "%22")}\""); + if (!string.IsNullOrWhiteSpace(e.Title)) + { + sb.AppendLine($" title: \"{e.Title.Replace("\"", "'")}\""); + } + if (e.VersionId > 0) + { + sb.AppendLine($" version_id: {e.VersionId}"); + } + } + } + return sb.ToString(); + } + + public async Task AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0) + { + // Pull Civitai sidecar next to weight + optional API version for examples. + await Task.CompletedTask; + string set = SetNameForKind(kind); + string weight = ModelWeightPath(set, name); + JObject civitai = null; + JArray exampleUrls = []; + if (!string.IsNullOrWhiteSpace(weight)) + { + string stem = Path.GetFileNameWithoutExtension(weight); + string dir = Path.GetDirectoryName(weight); + string side = Path.Combine(dir ?? "", $"{stem}.civitai.json"); + if (File.Exists(side)) + { + try + { + civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8)); + } + catch + { + // ignore + } + } + } + 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 + } + } + JObject card = ReadCardObject(kind, name); + string trigger = null; + try + { + if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h) + && h.Models.TryGetValue(name, out T2IModel m)) + { + trigger = m.Metadata?.TriggerPhrase; + } + } + catch + { + // ignore + } + return new JObject + { + ["success"] = true, + ["kind"] = kind, + ["name"] = name, + ["version_id"] = version_id, + ["trigger_phrase"] = trigger, + ["has_card"] = card is not null, + ["card"] = card, + ["civitai"] = civitai, + ["example_urls"] = exampleUrls, + ["weight_path"] = weight, + }; + } + + /// 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) + { + await Task.CompletedTask; + if (rescan) + { + try + { + Program.RefreshAllModelSets(); + } + catch (Exception ex) + { + Logs.Debug($"AssistentListInventory rescan: {ex.Message}"); + try + { + Program.ModelRefreshEvent?.Invoke(); + } + catch (Exception ex2) + { + Logs.Debug($"AssistentListInventory ModelRefreshEvent: {ex2.Message}"); + } + } + } + JArray loras = []; JArray checkpoints = []; JArray wildcards = []; if (Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler loraHandler)) { - foreach (T2IModel model in loraHandler.Models.Values.OrderBy(m => m.Name).Take(MaxLorasInInventory)) + foreach (T2IModel model in loraHandler.Models.Values + .OrderByDescending(m => LooksLikeKreaArch(m)) + .ThenBy(m => m.Name) + .Take(MaxLorasInInventory)) { - loras.Add(new JObject - { - ["name"] = model.Name, - ["title"] = model.Metadata?.Title ?? model.Title ?? model.Name, - ["trigger_phrase"] = model.Metadata?.TriggerPhrase, - ["architecture"] = model.ModelClass?.ID, - ["compat_class"] = model.ModelClass?.CompatClass?.ID, - ["hash"] = model.Metadata?.Hash ?? "", - }); + loras.Add(BuildInventoryModelEntry(model, "lora")); } } if (Program.T2IModelSets.TryGetValue("Stable-Diffusion", out T2IModelHandler ckptHandler)) { - foreach (T2IModel model in ckptHandler.Models.Values.OrderBy(m => m.Name).Take(60)) + foreach (T2IModel model in ckptHandler.Models.Values + .OrderByDescending(m => LooksLikeKreaArch(m)) + .ThenBy(m => m.Name) + .Take(MaxCheckpointsInInventory)) { - checkpoints.Add(new JObject - { - ["name"] = model.Name, - ["title"] = model.Metadata?.Title ?? model.Title ?? model.Name, - ["architecture"] = model.ModelClass?.ID, - ["compat_class"] = model.ModelClass?.CompatClass?.ID, - }); + checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint")); } } @@ -204,9 +724,125 @@ public class SwarmAssistentExtension : Extension ["checkpoints"] = checkpoints, ["wildcards"] = wildcards, ["has_civitai_key"] = hasCivitaiKey, + ["rescanned"] = rescan, + ["inventory_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), }; } + static bool LooksLikeKreaArch(T2IModel model) + { + string arch = model?.ModelClass?.ID ?? ""; + string compat = model?.ModelClass?.CompatClass?.ID ?? ""; + string name = model?.Name ?? ""; + string blob = $"{arch} {compat} {name}".ToLowerInvariant(); + return blob.Contains("krea"); + } + + JObject BuildInventoryModelEntry(T2IModel model, string kind) + { + string weight = null; + try { weight = model.RawFilePath; } catch { /* ignore */ } + string cardPath = CardPathForWeight(weight); + bool hasCard = !string.IsNullOrWhiteSpace(cardPath) && File.Exists(cardPath); + + string usage = model.Metadata?.UsageHint; + string desc = model.Metadata?.Description; + try + { + if (string.IsNullOrWhiteSpace(desc) && !string.IsNullOrWhiteSpace(model.Description)) + { + desc = model.Description; + } + } + catch + { + // older Swarm builds + } + + string blurb = null; + if (hasCard) + { + try + { + JObject card = JObject.Parse(File.ReadAllText(cardPath, Encoding.UTF8)); + string fromCard = (card["notes"] ?? card["when"] ?? card["prompt_hint"])?.ToString(); + if (!string.IsNullOrWhiteSpace(fromCard)) + { + blurb = Clip(fromCard.Trim(), InventoryBlurbMax); + } + } + catch + { + // ignore bad card json + } + } + if (string.IsNullOrWhiteSpace(blurb)) + { + string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc; + if (!string.IsNullOrWhiteSpace(raw)) + { + blurb = Clip(CollapseWs(raw), InventoryBlurbMax); + } + } + + JArray tags = null; + if (model.Metadata?.Tags is { Length: > 0 } tagArr) + { + tags = new JArray(tagArr.Where(t => !string.IsNullOrWhiteSpace(t)).Take(8)); + } + + string trigger = model.Metadata?.TriggerPhrase; + JArray triggers = null; + if (!string.IsNullOrWhiteSpace(trigger)) + { + triggers = new JArray(trigger.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Take(12)); + } + + JObject entry = new() + { + ["name"] = model.Name, + ["title"] = model.Metadata?.Title ?? model.Title ?? model.Name, + ["kind"] = kind, + ["trigger_phrase"] = trigger, + ["architecture"] = model.ModelClass?.ID, + ["compat_class"] = model.ModelClass?.CompatClass?.ID, + ["hash"] = model.Metadata?.Hash ?? "", + ["has_card"] = hasCard, + ["krea_likely"] = LooksLikeKreaArch(model), + }; + if (triggers is not null && triggers.Count > 0) + { + entry["triggers"] = triggers; + } + if (!string.IsNullOrWhiteSpace(blurb)) + { + entry["blurb"] = blurb; + } + if (!string.IsNullOrWhiteSpace(usage)) + { + entry["usage_hint"] = Clip(CollapseWs(usage), 120); + } + if (tags is not null && tags.Count > 0) + { + entry["tags"] = tags; + } + string defW = model.Metadata?.LoraDefaultWeight; + if (!string.IsNullOrWhiteSpace(defW) && kind == "lora") + { + entry["default_weight"] = defW; + } + return entry; + } + + static string CollapseWs(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return ""; + } + return Regex.Replace(text.Trim(), @"\s+", " "); + } + /// Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key. public async Task AssistentSearchCivitai(Session session, string query, int limit = 8) { @@ -368,7 +1004,7 @@ public class SwarmAssistentExtension : Extension }; } - List BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null) + List BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null) { List ollamaMessages = []; StringBuilder system = new(); @@ -380,6 +1016,13 @@ public class SwarmAssistentExtension : Extension system.AppendLine(basePack); } } + string personaPrompt = ResolvePersonaPrompt(personaId); + if (!string.IsNullOrWhiteSpace(personaPrompt)) + { + system.AppendLine(); + system.AppendLine($"## Persona: {personaId ?? "neutral"}"); + system.AppendLine(personaPrompt); + } if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2") { string situational = ReadPackFile(packName); @@ -431,6 +1074,42 @@ public class SwarmAssistentExtension : Extension return ollamaMessages; } + string ResolvePersonaPrompt(string personaId) + { + string id = (personaId ?? "neutral").Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + id = "neutral"; + } + string overlay = PersonasOverlayJsonPath(); + if (File.Exists(overlay)) + { + try + { + JObject parsed = JObject.Parse(File.ReadAllText(overlay, Encoding.UTF8)); + if (parsed["personas"] is JArray arr) + { + foreach (JToken t in arr) + { + if (t is JObject po && string.Equals(po["id"]?.ToString(), id, StringComparison.OrdinalIgnoreCase)) + { + string p = po["prompt"]?.ToString(); + if (!string.IsNullOrWhiteSpace(p)) + { + return p; + } + } + } + } + } + catch + { + // fall through to bundled + } + } + return ReadPersonaFile(id); + } + static JObject TryParsePatch(string reply) { if (string.IsNullOrWhiteSpace(reply)) @@ -528,9 +1207,10 @@ public class SwarmAssistentExtension : Extension string contextJson, JArray userMessages, Func onDelta = null, - Func onHopStart = null) + Func onHopStart = null, + string personaId = null) { - List messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages); + List messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages, personaId: personaId); JArray civitaiResults = []; string reply = ""; JObject lastRaw = null; @@ -653,7 +1333,7 @@ public class SwarmAssistentExtension : Extension /// SwarmUI passes the whole request as the JObject param (not only a nested key). /// Support both flat fields and legacy nested raw. /// - static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson) + static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona) { JObject whole = raw ?? []; JObject nested = whole["raw"] as JObject; @@ -678,12 +1358,13 @@ public class SwarmAssistentExtension : Extension } userMessages = (whole["messages"] as JArray) ?? (nested?["messages"] as JArray); contextJson = whole["context_json"]?.ToString() ?? nested?["context_json"]?.ToString(); + persona = whole["persona"]?.ToString() ?? nested?["persona"]?.ToString() ?? "neutral"; } /// Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop. public async Task AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw) { - ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson); + ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona); string root = NormalizeBaseUrl(baseUrl); string modelName = (model ?? "").Trim(); if (string.IsNullOrWhiteSpace(modelName)) @@ -698,13 +1379,14 @@ public class SwarmAssistentExtension : Extension try { (string reply, JObject parsed, JArray civitai) = await RunChatWithHops( - session, root, modelName, packName, includeBase, contextJson, userMessages); + session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona); return new JObject { ["success"] = true, ["reply"] = reply, ["model"] = modelName, ["pack"] = packName, + ["persona"] = persona, ["raw"] = parsed, ["civitai_results"] = civitai, }; @@ -718,7 +1400,7 @@ public class SwarmAssistentExtension : Extension /// WebSocket streaming chat (Ollama stream:true) + Civitai hops. public async Task AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw) { - ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson); + ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona); string root = NormalizeBaseUrl(baseUrl); string modelName = (model ?? "").Trim(); if (string.IsNullOrWhiteSpace(modelName)) @@ -762,7 +1444,7 @@ public class SwarmAssistentExtension : Extension } } (string reply, JObject parsed, JArray civitai) = await RunChatWithHops( - session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart); + session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona); await ws.SendJson(new JObject { ["success"] = true, @@ -770,6 +1452,7 @@ public class SwarmAssistentExtension : Extension ["reply"] = reply, ["model"] = modelName, ["pack"] = packName, + ["persona"] = persona, ["raw"] = parsed, ["civitai_results"] = civitai, }, API.WebsocketTimeout); diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index 00856c4..6b948c1 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -24,8 +24,15 @@
Assistent +
+ + +
+ Auto-critique after generate
-
-
-
Collaborative Krea 2
-
Write a prompt, drop refs, use aspect chips, or type /help.
+ +
+
+
+
Collaborative Krea 2
+
Write a prompt, drop refs, pick a persona, or open Cards for LoRA tips.
+
+
+ +
+ + +
+ + + + +
- -
- - -
- - - - + +