Add personas and model Cards (0.5.1): tone presets, stem.assistent.json, wanted queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 21:25:01 +03:00
co-authored by Cursor
parent b8145c6455
commit 0b429542b5
10 changed files with 1573 additions and 84 deletions
+145
View File
@@ -770,6 +770,142 @@
100% { box-shadow: 0 0 0 0 transparent; } 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) { @media (max-width: 900px) {
.sa-layout { .sa-layout {
flex-direction: column; flex-direction: column;
@@ -786,4 +922,13 @@
.sa-splitter { .sa-splitter {
display: none; 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);
}
} }
+576 -23
View File
@@ -1,11 +1,13 @@
/** /**
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API). * 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 () { (function () {
const LS_BASE = 'swarm_assistent_base_url'; const LS_BASE = 'swarm_assistent_base_url';
const LS_MODEL = 'swarm_assistent_model'; const LS_MODEL = 'swarm_assistent_model';
const LS_PACK = 'swarm_assistent_pack'; 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_VISION = 'swarm_assistent_auto_vision';
const LS_AUTO_APPLY = 'swarm_assistent_auto_apply'; const LS_AUTO_APPLY = 'swarm_assistent_auto_apply';
const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate'; const LS_AUTO_GENERATE = 'swarm_assistent_auto_generate';
@@ -13,6 +15,7 @@
const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download'; const LS_AUTO_DOWNLOAD = 'swarm_assistent_auto_download';
const LS_PANE_WIDTH = 'swarm_assistent_pane_width'; const LS_PANE_WIDTH = 'swarm_assistent_pane_width';
const LS_WELCOMED = 'swarm_assistent_welcomed'; const LS_WELCOMED = 'swarm_assistent_welcomed';
const LS_TASTE = 'swarm_assistent_taste';
const TAB_BUTTON_ID = 'maintab_assistent'; const TAB_BUTTON_ID = 'maintab_assistent';
const GEN_ID = 'generate'; const GEN_ID = 'generate';
const MAX_REF_SLOTS = 4; const MAX_REF_SLOTS = 4;
@@ -41,6 +44,9 @@
inpaint_edit: 'inpaint_edit', inpaint_edit: 'inpaint_edit',
describe: 'describe_ref', describe: 'describe_ref',
describe_ref: 'describe_ref', describe_ref: 'describe_ref',
card: 'catalog_card',
catalog: 'catalog_card',
catalog_card: 'catalog_card',
}; };
const WELCOME_HTML = ` const WELCOME_HTML = `
@@ -65,6 +71,7 @@
/vary — новый seed, тот же промпт /vary — новый seed, тот же промпт
/pack write|critique|compose|params|inpaint|describe /pack write|critique|compose|params|inpaint|describe
/civitai <query> — поиск LoRA (Confirm в чате) /civitai <query> — поиск LoRA (Confirm в чате)
/inventory — rescan моделей + обновить список LoRA
Чипсы над полем ввода делают то же для aspect / seed / vary.`; Чипсы над полем ввода делают то же для aspect / seed / vary.`;
@@ -77,6 +84,8 @@
preferredModel: null, preferredModel: null,
dragDepth: 0, dragDepth: 0,
inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false }, inventory: { loras: [], checkpoints: [], wildcards: [], has_civitai_key: false },
inventoryFetchedAt: 0,
taste: { styles: [], likes: [], avoid: [], notes: '', updated: 0 },
streamEl: null, streamEl: null,
critiqueHopUsed: false, critiqueHopUsed: false,
visionHopUsed: false, visionHopUsed: false,
@@ -88,6 +97,11 @@
selectedSlotId: 'ref1', selectedSlotId: 'ref1',
refSeq: 1, refSeq: 1,
packUserTouched: false, packUserTouched: false,
view: 'chat',
personas: [],
modelCards: {},
cardsSelection: null,
cardsBusy: false,
}; };
function $(id) { function $(id) {
@@ -1100,14 +1114,18 @@
batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null, batch: parseInt(val('input_images') || val('input_batchsize') || '0', 10) || null,
prompt_image_count: countPromptImages(), prompt_image_count: countPromptImages(),
selected_loras: [], 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), wildcards: (inv.wildcards || []).map((w) => w.name || w).slice(0, 60),
inventory_at: inv.inventory_at || null,
has_vision_image: attachableSlots().length > 0, has_vision_image: attachableSlots().length > 0,
image_slots: slotCatalog(), image_slots: slotCatalog(),
attached_slot_ids: attachableSlots().map((s) => s.id), attached_slot_ids: attachableSlots().map((s) => s.id),
has_civitai_key: !!inv.has_civitai_key, has_civitai_key: !!inv.has_civitai_key,
auto_apply: !!$('sa_auto_apply')?.checked, auto_apply: !!$('sa_auto_apply')?.checked,
auto_generate: !!$('sa_auto_generate')?.checked, auto_generate: !!$('sa_auto_generate')?.checked,
persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral',
model_cards: [],
...initCtx, ...initCtx,
}; };
@@ -1131,6 +1149,23 @@
} }
} catch (e) { /* ignore */ } } 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 // Fallback if inventory empty
if (!ctx.available_loras.length) { if (!ctx.available_loras.length) {
try { try {
@@ -1162,6 +1197,58 @@
return ctx; 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) { function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return false; return false;
@@ -1205,10 +1292,50 @@
obj.movement != null || obj.movement != null ||
obj.clear_prompt_images != null || obj.clear_prompt_images != null ||
obj.slot_to_prompt_image != 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) { function extractPatch(text) {
if (!text) { if (!text) {
return { prose: text || '', patch: null }; 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.`; $('sa_input').value = `LoRA "${payload.name}" is now installed. Enable it with its triggers and improve the prompt.`;
} }
sendChat({ fromDownload: true }); sendChat({ fromDownload: true });
}); }, { rescan: true });
} else { } else {
setStatus(msg || 'Download failed'); setStatus(msg || 'Download failed');
if (btn) { if (btn) {
@@ -1866,7 +1993,8 @@
} }
if (data.success || data.overall_percent >= 1 || data.current_percent >= 1) { if (data.success || data.overall_percent >= 1 || data.current_percent >= 1) {
if (data.success || data.overall_percent >= 0.99) { 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) { } else if (data.current_percent != null) {
setStatus(`Download ${(data.current_percent * 100).toFixed(0)}%`); setStatus(`Download ${(data.current_percent * 100).toFixed(0)}%`);
} }
@@ -2051,6 +2179,8 @@
const base = localStorage.getItem(LS_BASE); const base = localStorage.getItem(LS_BASE);
const model = localStorage.getItem(LS_MODEL); const model = localStorage.getItem(LS_MODEL);
const pack = localStorage.getItem(LS_PACK); 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 auto = localStorage.getItem(LS_AUTO_VISION);
const autoApply = localStorage.getItem(LS_AUTO_APPLY); const autoApply = localStorage.getItem(LS_AUTO_APPLY);
const autoGen = localStorage.getItem(LS_AUTO_GENERATE); const autoGen = localStorage.getItem(LS_AUTO_GENERATE);
@@ -2063,6 +2193,9 @@
if (pack && $('sa_pack')) { if (pack && $('sa_pack')) {
$('sa_pack').value = pack; $('sa_pack').value = pack;
} }
if (persona && $('sa_persona')) {
$('sa_persona').value = persona;
}
if (auto != null && $('sa_auto_vision')) { if (auto != null && $('sa_auto_vision')) {
$('sa_auto_vision').checked = auto === '1'; $('sa_auto_vision').checked = auto === '1';
} }
@@ -2084,12 +2217,17 @@
if (paneW) { if (paneW) {
document.documentElement.style.setProperty('--sa-image-width', paneW); document.documentElement.style.setProperty('--sa-image-width', paneW);
} }
if (view === 'cards' || view === 'chat') {
state.view = view;
}
} }
function saveSettings() { function saveSettings() {
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || ''); localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
localStorage.setItem(LS_MODEL, $('sa_model')?.value || ''); localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
localStorage.setItem(LS_PACK, $('sa_pack')?.value || 'write_prompt'); 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_VISION, $('sa_auto_vision')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_APPLY, $('sa_auto_apply')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_APPLY, $('sa_auto_apply')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_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 = '<div class="sa-chat-empty-hint">Inventory empty — Refresh.</div>';
return;
}
for (const row of rows) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'sa-card-row';
if (state.cardsSelection && state.cardsSelection.kind === row.kind && state.cardsSelection.name === row.name) {
btn.classList.add('sa-selected');
}
btn.innerHTML = `<div class="sa-card-row-kind">${row.kind}</div><div>${escapeHtml(row.title || row.name)}</div><div class="sa-card-row-meta">${row.has_card ? 'card ✓' : 'no card'}${row.trigger ? ' · ' + escapeHtml(String(row.trigger).slice(0, 40)) : ''}</div>`;
btn.addEventListener('click', () => selectCardModel(row));
root.appendChild(btn);
}
}
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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 12 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 (typeof genericRequest !== 'function') {
if (done) { if (done) {
done(); done();
@@ -2171,23 +2669,15 @@
return; return;
} }
genericRequest( genericRequest(
'AssistentListInventory', 'TriggerRefresh',
{}, { strong: true },
(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`);
if (done) { if (done) {
done(); done();
} }
}, },
0, 0,
(err) => { () => {
console.warn('Assistent inventory', err);
if (done) { if (done) {
done(); done();
} }
@@ -2196,7 +2686,16 @@
} }
async function handleReplySideEffects(reply, civitaiResults, opts = {}) { 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); const { patch } = extractPatch(reply);
if (Array.isArray(patch?.actions) && patch.actions.map(String).includes('interrupt')) { if (Array.isArray(patch?.actions) && patch.actions.map(String).includes('interrupt')) {
doInterruptNow(); doInterruptNow();
@@ -2389,6 +2888,17 @@
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)'); await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)');
return true; 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 (cmd === 'pack') {
if (!setPackValue(arg, { flash: true, user: true })) { if (!setPackValue(arg, { flash: true, user: true })) {
setStatus('Pack: write|critique|compose|params|inpaint|describe'); setStatus('Pack: write|critique|compose|params|inpaint|describe');
@@ -2483,14 +2993,20 @@
return; 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); const guessed = autoSelectPack(text);
if (guessed) { if (guessed) {
setPackValue(guessed, { flash: true }); 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 pack = $('sa_pack')?.value || 'write_prompt';
const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
const model = $('sa_model')?.value; const model = $('sa_model')?.value;
if (!model) { if (!model) {
setStatus('Pick an Ollama model in ⚙'); setStatus('Pick an Ollama model in ⚙');
@@ -2498,6 +3014,11 @@
return; 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) { if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop) {
state.critiqueHopUsed = false; state.critiqueHopUsed = false;
state.visionHopUsed = false; state.visionHopUsed = false;
@@ -2534,6 +3055,18 @@
const context = collectLiveContext(); const context = collectLiveContext();
context.has_vision_image = !!images; context.has_vision_image = !!images;
context.attached_slot_ids = visionSlots.map((s) => s.id); 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 })); const messages = state.history.slice(-12).map((m) => ({ role: m.role, content: m.content }));
if (images && messages.length) { if (images && messages.length) {
messages[messages.length - 1].images = images; messages[messages.length - 1].images = images;
@@ -2550,6 +3083,7 @@
baseUrl, baseUrl,
model, model,
pack, pack,
persona,
includeBase: true, includeBase: true,
messages, messages,
context_json: JSON.stringify(context), context_json: JSON.stringify(context),
@@ -2557,6 +3091,7 @@
messages, messages,
context_json: JSON.stringify(context), context_json: JSON.stringify(context),
pack, pack,
persona,
base_url: baseUrl, base_url: baseUrl,
model, model,
}, },
@@ -2815,6 +3350,7 @@
} }
window.__swarmAssistentWired = true; window.__swarmAssistentWired = true;
loadSettings(); loadSettings();
setView(state.view || 'chat');
updateGate(); updateGate();
ensureBoard(); ensureBoard();
renderBoard(); renderBoard();
@@ -2824,11 +3360,26 @@
} }
maybeWelcome(); maybeWelcome();
refreshModels(); refreshModels();
refreshPersonas();
refreshInventory(); refreshInventory();
wireDropZone(); wireDropZone();
wireSplitter(); wireSplitter();
registerSendButton(); 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', () => { $('sa_btn_settings')?.addEventListener('click', () => {
const s = $('sa_settings'); const s = $('sa_settings');
if (s) { if (s) {
@@ -2839,7 +3390,7 @@
saveSettings(); saveSettings();
refreshModels(); 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_add_ref')?.addEventListener('click', () => addRefSlot({ select: true }));
$('sa_btn_use_current')?.addEventListener('click', () => snapshotGenerateToRef()); $('sa_btn_use_current')?.addEventListener('click', () => snapshotGenerateToRef());
$('sa_btn_as_init')?.addEventListener('click', async () => { $('sa_btn_as_init')?.addEventListener('click', async () => {
@@ -2951,9 +3502,11 @@
setInterval(syncGenerateSlot, 700); setInterval(syncGenerateSlot, 700);
setInterval(() => { setInterval(() => {
if (!state.busy) { 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 = { window.swarmAssistent = {
setImageFromSrc, setImageFromSrc,
+8
View File
@@ -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.
+8
View File
@@ -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.
+8
View File
@@ -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.
+5 -1
View File
@@ -48,10 +48,14 @@ Prefer `aspect` in the patch; UI maps it to width/height.
## Live context ## 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. - 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. - 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. - 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`. - 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). - `wildcards` lists installed wildcard names (`__name__` syntax in prompts).
+34
View File
@@ -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": "13 sentences for the agent"
}
```
## Rules
- Prefer triggers from metadata / trainedWords — **never invent**.
- `weight` typical 0.61.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.
+6 -1
View File
@@ -72,8 +72,13 @@ Restart / rebuild SwarmUI after clone.
| `fix_params` | Aspect / steps / CFG / seed / batch | | `fix_params` | Aspect / steps / CFG / seed / batch |
| `inpaint_edit` | Init Image img2img + Mask inpaint | | `inpaint_edit` | Init Image img2img + Mask inpaint |
| `describe_ref` | Vision → Krea prompt (no generate by default) | | `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. **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.
+715 -32
View File
@@ -39,11 +39,16 @@ public class SwarmAssistentExtension : Extension
"fix_params", "fix_params",
"inpaint_edit", "inpaint_edit",
"describe_ref", "describe_ref",
"catalog_card",
]; ];
public static readonly string[] DefaultPersonaIds = ["neutral", "lewd", "aggressive"];
const int MaxCivitaiHops = 2; const int MaxCivitaiHops = 2;
const int MaxLorasInInventory = 120; const int MaxLorasInInventory = 150;
const int MaxWildcardsInInventory = 80; const int MaxWildcardsInInventory = 80;
const int MaxCheckpointsInInventory = 60;
const int InventoryBlurbMax = 140;
/// <summary>Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that.</summary> /// <summary>Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that.</summary>
const int DefaultNumCtx = 16384; const int DefaultNumCtx = 16384;
@@ -54,9 +59,9 @@ public class SwarmAssistentExtension : Extension
ScriptFiles.Add("Assets/assistent.js"); ScriptFiles.Add("Assets/assistent.js");
StyleSheetFiles.Add("Assets/assistent.css"); StyleSheetFiles.Add("Assets/assistent.css");
ExtensionAuthor = "mrleo1nid"; 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"; License = "MIT";
Version = "0.5.0"; Version = "0.5.3";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"];
} }
@@ -65,11 +70,16 @@ public class SwarmAssistentExtension : Extension
HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
API.RegisterAPICall(AssistentListModels, false, PermUse); API.RegisterAPICall(AssistentListModels, false, PermUse);
API.RegisterAPICall(AssistentGetPacks, false, PermUse); API.RegisterAPICall(AssistentGetPacks, false, PermUse);
API.RegisterAPICall(AssistentListPersonas, false, PermUse);
API.RegisterAPICall(AssistentListInventory, 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(AssistentSearchCivitai, false, PermUse);
API.RegisterAPICall(AssistentChat, true, PermUse); API.RegisterAPICall(AssistentChat, true, PermUse);
API.RegisterAPICall(AssistentChatWS, 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) 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) }; return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = new JArray(PackNames) };
} }
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).</summary> static string DataRoot()
public async Task<JObject> AssistentListInventory(Session session) {
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<JObject> AssistentListPersonas(Session session)
{ {
await Task.CompletedTask; await Task.CompletedTask;
Dictionary<string, JObject> 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<JObject> 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<JObject> 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<int?>() ?? 0, card["title"]?.ToString() ?? name, card);
}
return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
}
public async Task<JObject> 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<string, List<WantedEntry>> sections = LoadWantedYaml(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : "");
if (version_id > 0)
{
foreach (List<WantedEntry> 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<WantedEntry> 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<WantedEntry> 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<string, List<WantedEntry>> LoadWantedYaml(string raw)
{
Dictionary<string, List<WantedEntry>> 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<WantedEntry> 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<string, List<WantedEntry>> 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<string> 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<WantedEntry> 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<JObject> 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<int?>() ?? 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,
};
}
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).</summary>
public async Task<JObject> 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 loras = [];
JArray checkpoints = []; JArray checkpoints = [];
JArray wildcards = []; JArray wildcards = [];
if (Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler loraHandler)) 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 loras.Add(BuildInventoryModelEntry(model, "lora"));
{
["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 ?? "",
});
} }
} }
if (Program.T2IModelSets.TryGetValue("Stable-Diffusion", out T2IModelHandler ckptHandler)) 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 checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint"));
{
["name"] = model.Name,
["title"] = model.Metadata?.Title ?? model.Title ?? model.Name,
["architecture"] = model.ModelClass?.ID,
["compat_class"] = model.ModelClass?.CompatClass?.ID,
});
} }
} }
@@ -204,9 +724,125 @@ public class SwarmAssistentExtension : Extension
["checkpoints"] = checkpoints, ["checkpoints"] = checkpoints,
["wildcards"] = wildcards, ["wildcards"] = wildcards,
["has_civitai_key"] = hasCivitaiKey, ["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+", " ");
}
/// <summary>Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key.</summary> /// <summary>Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key.</summary>
public async Task<JObject> AssistentSearchCivitai(Session session, string query, int limit = 8) public async Task<JObject> AssistentSearchCivitai(Session session, string query, int limit = 8)
{ {
@@ -368,7 +1004,7 @@ public class SwarmAssistentExtension : Extension
}; };
} }
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null) List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null)
{ {
List<JObject> ollamaMessages = []; List<JObject> ollamaMessages = [];
StringBuilder system = new(); StringBuilder system = new();
@@ -380,6 +1016,13 @@ public class SwarmAssistentExtension : Extension
system.AppendLine(basePack); 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") if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2")
{ {
string situational = ReadPackFile(packName); string situational = ReadPackFile(packName);
@@ -431,6 +1074,42 @@ public class SwarmAssistentExtension : Extension
return ollamaMessages; 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) static JObject TryParsePatch(string reply)
{ {
if (string.IsNullOrWhiteSpace(reply)) if (string.IsNullOrWhiteSpace(reply))
@@ -528,9 +1207,10 @@ public class SwarmAssistentExtension : Extension
string contextJson, string contextJson,
JArray userMessages, JArray userMessages,
Func<string, Task> onDelta = null, Func<string, Task> onDelta = null,
Func<int, Task> onHopStart = null) Func<int, Task> onHopStart = null,
string personaId = null)
{ {
List<JObject> messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages); List<JObject> messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages, personaId: personaId);
JArray civitaiResults = []; JArray civitaiResults = [];
string reply = ""; string reply = "";
JObject lastRaw = null; 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). /// SwarmUI passes the whole request as the JObject param (not only a nested key).
/// Support both flat fields and legacy nested <c>raw</c>. /// Support both flat fields and legacy nested <c>raw</c>.
/// </summary> /// </summary>
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 whole = raw ?? [];
JObject nested = whole["raw"] as JObject; JObject nested = whole["raw"] as JObject;
@@ -678,12 +1358,13 @@ public class SwarmAssistentExtension : Extension
} }
userMessages = (whole["messages"] as JArray) ?? (nested?["messages"] as JArray); userMessages = (whole["messages"] as JArray) ?? (nested?["messages"] as JArray);
contextJson = whole["context_json"]?.ToString() ?? nested?["context_json"]?.ToString(); contextJson = whole["context_json"]?.ToString() ?? nested?["context_json"]?.ToString();
persona = whole["persona"]?.ToString() ?? nested?["persona"]?.ToString() ?? "neutral";
} }
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary> /// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw) public async Task<JObject> 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 root = NormalizeBaseUrl(baseUrl);
string modelName = (model ?? "").Trim(); string modelName = (model ?? "").Trim();
if (string.IsNullOrWhiteSpace(modelName)) if (string.IsNullOrWhiteSpace(modelName))
@@ -698,13 +1379,14 @@ public class SwarmAssistentExtension : Extension
try try
{ {
(string reply, JObject parsed, JArray civitai) = await RunChatWithHops( (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 return new JObject
{ {
["success"] = true, ["success"] = true,
["reply"] = reply, ["reply"] = reply,
["model"] = modelName, ["model"] = modelName,
["pack"] = packName, ["pack"] = packName,
["persona"] = persona,
["raw"] = parsed, ["raw"] = parsed,
["civitai_results"] = civitai, ["civitai_results"] = civitai,
}; };
@@ -718,7 +1400,7 @@ public class SwarmAssistentExtension : Extension
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary> /// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw) public async Task<JObject> 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 root = NormalizeBaseUrl(baseUrl);
string modelName = (model ?? "").Trim(); string modelName = (model ?? "").Trim();
if (string.IsNullOrWhiteSpace(modelName)) if (string.IsNullOrWhiteSpace(modelName))
@@ -762,7 +1444,7 @@ public class SwarmAssistentExtension : Extension
} }
} }
(string reply, JObject parsed, JArray civitai) = await RunChatWithHops( (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 await ws.SendJson(new JObject
{ {
["success"] = true, ["success"] = true,
@@ -770,6 +1452,7 @@ public class SwarmAssistentExtension : Extension
["reply"] = reply, ["reply"] = reply,
["model"] = modelName, ["model"] = modelName,
["pack"] = packName, ["pack"] = packName,
["persona"] = persona,
["raw"] = parsed, ["raw"] = parsed,
["civitai_results"] = civitai, ["civitai_results"] = civitai,
}, API.WebsocketTimeout); }, API.WebsocketTimeout);
+68 -27
View File
@@ -24,8 +24,15 @@
<div class="sa-chat-title"> <div class="sa-chat-title">
Assistent Assistent
<span class="sa-live-dot" id="sa_live_dot" hidden></span> <span class="sa-live-dot" id="sa_live_dot" hidden></span>
<div class="sa-subtabs" role="tablist">
<button type="button" class="sa-subtab sa-subtab-active" data-view="chat" id="sa_tab_chat">Chat</button>
<button type="button" class="sa-subtab" data-view="cards" id="sa_tab_cards">Cards</button>
</div>
</div> </div>
<div class="sa-header-right"> <div class="sa-header-right">
<select id="sa_persona" class="sa-select" title="Character / tone">
<option value="neutral">Нейтральный</option>
</select>
<select id="sa_pack" class="sa-select" title="Prompt pack"> <select id="sa_pack" class="sa-select" title="Prompt pack">
<option value="write_prompt">Write prompt</option> <option value="write_prompt">Write prompt</option>
<option value="critique_image">Critique image</option> <option value="critique_image">Critique image</option>
@@ -33,6 +40,7 @@
<option value="fix_params">Fix params</option> <option value="fix_params">Fix params</option>
<option value="inpaint_edit">Inpaint / img2img</option> <option value="inpaint_edit">Inpaint / img2img</option>
<option value="describe_ref">Describe ref</option> <option value="describe_ref">Describe ref</option>
<option value="catalog_card">Catalog card</option>
</select> </select>
<select id="sa_model" class="sa-select sa-model-select" title="Ollama model"> <select id="sa_model" class="sa-select sa-model-select" title="Ollama model">
<option value="">Loading models…</option> <option value="">Loading models…</option>
@@ -50,35 +58,68 @@
<label class="sa-check"><input type="checkbox" id="sa_auto_critique" /> Auto-critique after generate</label> <label class="sa-check"><input type="checkbox" id="sa_auto_critique" /> Auto-critique after generate</label>
<label class="sa-check sa-danger" title="Dangerous — downloads without Confirm"><input type="checkbox" id="sa_auto_download" /> Auto-download Civitai (off)</label> <label class="sa-check sa-danger" title="Dangerous — downloads without Confirm"><input type="checkbox" id="sa_auto_download" /> Auto-download Civitai (off)</label>
</div> </div>
<div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty" id="sa_chat_empty"> <div class="sa-view" id="sa_view_chat">
<div class="sa-chat-empty-title">Collaborative Krea 2</div> <div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty-hint">Write a prompt, drop refs, use aspect chips, or type <code>/help</code>.</div> <div class="sa-chat-empty" id="sa_chat_empty">
<div class="sa-chat-empty-title">Collaborative Krea 2</div>
<div class="sa-chat-empty-hint">Write a prompt, drop refs, pick a persona, or open <em>Cards</em> for LoRA tips.</div>
</div>
</div>
<div class="sa-livebar" id="sa_livebar" hidden>
<span class="sa-spinner" aria-hidden="true"></span>
<span class="sa-livebar-text" id="sa_livebar_text">Working…</span>
<span class="sa-elapsed" id="sa_elapsed"></span>
</div>
<div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Quick params">
<button type="button" class="sa-chip" data-aspect="1:1" title="1024×1024">1:1</button>
<button type="button" class="sa-chip" data-aspect="4:5" title="928×1152">4:5</button>
<button type="button" class="sa-chip" data-aspect="2:3" title="832×1248">2:3</button>
<button type="button" class="sa-chip" data-aspect="16:9" title="1376×768">16:9</button>
<button type="button" class="sa-chip" data-aspect="9:16" title="768×1376">9:16</button>
<span class="sa-chip-sep" aria-hidden="true"></span>
<button type="button" class="sa-chip" data-seed="lock" title="Keep current seed">Seed lock</button>
<button type="button" class="sa-chip" data-seed="random" title="Random seed">Seed 1</button>
<button type="button" class="sa-chip" data-vary="1" title="Same prompt, new seed + generate">Vary</button>
</div>
<textarea id="sa_input" rows="3" placeholder="Ask for a prompt, img2img, critique… Enter = send · /help = commands"></textarea>
<div class="sa-composer-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_send">Send</button>
<button type="button" class="basic-button" id="sa_btn_interrupt" title="Interrupt generation / chat" hidden>Interrupt</button>
<button type="button" class="basic-button" id="sa_btn_clear">Clear chat</button>
<span class="sa-status" id="sa_status"></span>
</div>
</div> </div>
</div> </div>
<div class="sa-livebar" id="sa_livebar" hidden>
<span class="sa-spinner" aria-hidden="true"></span> <div class="sa-view" id="sa_view_cards" hidden>
<span class="sa-livebar-text" id="sa_livebar_text">Working…</span> <div class="sa-cards-layout">
<span class="sa-elapsed" id="sa_elapsed"></span> <div class="sa-cards-list-pane">
</div> <div class="sa-cards-filter">
<div class="sa-composer" id="sa_composer"> <select id="sa_cards_kind" class="sa-select">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Quick params"> <option value="all">All</option>
<button type="button" class="sa-chip" data-aspect="1:1" title="1024×1024">1:1</button> <option value="checkpoint">Checkpoints</option>
<button type="button" class="sa-chip" data-aspect="4:5" title="928×1152">4:5</button> <option value="lora">LoRAs</option>
<button type="button" class="sa-chip" data-aspect="2:3" title="832×1248">2:3</button> </select>
<button type="button" class="sa-chip" data-aspect="16:9" title="1376×768">16:9</button> <button type="button" class="basic-button" id="sa_btn_cards_refresh">Refresh</button>
<button type="button" class="sa-chip" data-aspect="9:16" title="768×1376">9:16</button> </div>
<span class="sa-chip-sep" aria-hidden="true"></span> <div class="sa-cards-list" id="sa_cards_list"></div>
<button type="button" class="sa-chip" data-seed="lock" title="Keep current seed">Seed lock</button> </div>
<button type="button" class="sa-chip" data-seed="random" title="Random seed">Seed 1</button> <div class="sa-cards-editor-pane">
<button type="button" class="sa-chip" data-vary="1" title="Same prompt, new seed + generate">Vary</button> <div class="sa-cards-editor-head">
</div> <strong id="sa_card_title">Select a model</strong>
<textarea id="sa_input" rows="3" placeholder="Ask for a prompt, img2img, critique… Enter = send · /help = commands"></textarea> <span class="sa-card-badge" id="sa_card_badge" hidden></span>
<div class="sa-composer-actions"> </div>
<button type="button" class="basic-button sa-primary" id="sa_btn_send">Send</button> <textarea id="sa_card_json" rows="16" placeholder="Card JSON will appear here…" spellcheck="false"></textarea>
<button type="button" class="basic-button" id="sa_btn_interrupt" title="Interrupt generation / chat" hidden>Interrupt</button> <div class="sa-cards-actions">
<button type="button" class="basic-button" id="sa_btn_clear">Clear chat</button> <button type="button" class="basic-button" id="sa_btn_card_meta">Load Civitai meta</button>
<span class="sa-status" id="sa_status"></span> <button type="button" class="basic-button sa-primary" id="sa_btn_card_generate">Generate with Assistent</button>
<button type="button" class="basic-button" id="sa_btn_card_save">Save card</button>
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="Add to next-up download list">Enqueue wanted</button>
<span class="sa-status" id="sa_card_status"></span>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>