diff --git a/Assets/assistent.api.js b/Assets/assistent.api.js
new file mode 100644
index 0000000..6302df3
--- /dev/null
+++ b/Assets/assistent.api.js
@@ -0,0 +1,27 @@
+/**
+ * Swarm Assistent — promise wrapper around SwarmUI's genericRequest.
+ * Loaded before assistent.js.
+ */
+window.SA = window.SA || {};
+
+SA.request = function (name, body) {
+ return new Promise((resolve, reject) => {
+ if (typeof genericRequest !== 'function') {
+ reject(new Error('genericRequest unavailable'));
+ return;
+ }
+ genericRequest(
+ name,
+ body || {},
+ (data) => {
+ if (data && data.error) {
+ reject(new Error(String(data.error)));
+ } else {
+ resolve(data);
+ }
+ },
+ 0,
+ (err) => reject(err instanceof Error ? err : new Error(String(err || 'request failed'))),
+ );
+ });
+};
diff --git a/Assets/assistent.css b/Assets/assistent.css
index 32644dc..4d20304 100644
--- a/Assets/assistent.css
+++ b/Assets/assistent.css
@@ -84,16 +84,6 @@
opacity: 0.9;
}
-.sa-board-hint {
- flex: 1;
- font-size: 0.75rem;
- opacity: 0.55;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
.sa-board {
flex: 1;
display: grid;
@@ -247,43 +237,6 @@
opacity: 0.9;
}
-.sa-image-frame {
- position: relative;
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- border: 1px dashed color-mix(in srgb, currentColor 28%, transparent);
- border-radius: 0.55rem;
- overflow: hidden;
- background:
- radial-gradient(ellipse at 30% 20%, color-mix(in srgb, currentColor 8%, transparent), transparent 55%),
- color-mix(in srgb, currentColor 4%, transparent);
- min-height: 14rem;
- outline: none;
- transition: border-color 0.15s ease, box-shadow 0.15s ease;
-}
-
-.sa-image-frame:focus-visible {
- box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 35%, transparent);
-}
-
-.sa-image-frame.sa-has-image {
- border-style: solid;
-}
-
-.sa-image-frame.sa-dragover {
- border-color: color-mix(in srgb, currentColor 70%, transparent);
- box-shadow: inset 0 0 0 2px color-mix(in srgb, currentColor 25%, transparent);
-}
-
-.sa-image-frame img {
- max-width: 100%;
- max-height: 100%;
- object-fit: contain;
- display: block;
-}
-
.sa-image-empty {
padding: 1.25rem 1rem;
text-align: center;
@@ -302,32 +255,6 @@
opacity: 0.9;
}
-.sa-drop-overlay {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background: color-mix(in srgb, currentColor 18%, transparent);
- font-weight: 600;
- letter-spacing: 0.04em;
- pointer-events: none;
-}
-
-.sa-vision-chip {
- position: absolute;
- top: 0.45rem;
- left: 0.45rem;
- padding: 0.15rem 0.45rem;
- border-radius: 999px;
- font-size: 0.72rem;
- font-weight: 600;
- letter-spacing: 0.04em;
- text-transform: uppercase;
- background: color-mix(in srgb, currentColor 16%, transparent);
- border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
-}
-
.sa-image-actions {
display: flex;
flex-wrap: wrap;
@@ -786,6 +713,132 @@
margin-bottom: 0.25rem;
}
+.sa-mem-head {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: 0.35rem;
+}
+
+.sa-mem-head .sa-skills-label {
+ flex: 1;
+ margin-top: 0;
+}
+
+.sa-settings .sa-mem-kind {
+ width: auto;
+ min-width: 8rem;
+ max-width: 12rem;
+}
+
+.sa-mem-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ max-height: 14rem;
+ overflow: auto;
+ padding: 0.25rem;
+ border-radius: 0.4rem;
+ border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
+ background: color-mix(in srgb, currentColor 4%, transparent);
+}
+
+.sa-mem-empty {
+ font-size: 0.8rem;
+ opacity: 0.65;
+ padding: 0.35rem 0.25rem;
+}
+
+.sa-mem-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.35rem;
+ padding: 0.3rem 0.35rem;
+ border-radius: 0.35rem;
+ background: color-mix(in srgb, currentColor 4%, transparent);
+}
+
+.sa-mem-row-body {
+ flex: 1;
+ min-width: 0;
+}
+
+.sa-mem-row-head {
+ display: flex;
+ align-items: baseline;
+ gap: 0.35rem;
+}
+
+.sa-mem-kind-badge {
+ font-size: 0.62rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ opacity: 0.55;
+}
+
+.sa-mem-row-key {
+ font-size: 0.82rem;
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.sa-mem-row-text {
+ font-size: 0.76rem;
+ opacity: 0.78;
+ line-height: 1.3;
+}
+
+.sa-mem-row-meta {
+ font-size: 0.68rem;
+ opacity: 0.5;
+}
+
+.sa-mem-forget {
+ flex: 0 0 auto;
+ padding: 0.1rem 0.4rem !important;
+ font-size: 0.85rem;
+}
+
+.sa-mem-foot {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 0.35rem;
+ font-size: 0.72rem;
+ opacity: 0.7;
+}
+
+.sa-health {
+ font-size: 0.7rem;
+ padding: 0.05rem 0.4rem;
+ border-radius: 0.75rem;
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
+ cursor: pointer;
+ white-space: nowrap;
+ opacity: 0.85;
+}
+
+.sa-health-ok {
+ color: color-mix(in srgb, #6ee7a8 75%, currentColor);
+ border-color: color-mix(in srgb, #6ee7a8 40%, transparent);
+}
+
+.sa-health-warn {
+ color: color-mix(in srgb, #e3b341 80%, currentColor);
+ border-color: color-mix(in srgb, #e3b341 40%, transparent);
+}
+
+.sa-health-down {
+ color: color-mix(in srgb, #f2777a 80%, currentColor);
+ border-color: color-mix(in srgb, #f2777a 45%, transparent);
+}
+
+.sa-card-row-wanted {
+ border-color: color-mix(in srgb, #e3b341 40%, transparent);
+}
+
.sa-settings .sa-select {
width: 100%;
}
diff --git a/Assets/assistent.js b/Assets/assistent.js
index 8ffc9af..39e31a3 100644
--- a/Assets/assistent.js
+++ b/Assets/assistent.js
@@ -1,6 +1,6 @@
/**
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
- * v0.7.6: Cheap-bug pass — abort on clear/new, busy until side-effects, interrupt cleans stream.
+ * v0.8.0: Split assets — SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
*/
(function () {
const LS_BASE = 'swarm_assistent_base_url';
@@ -24,8 +24,9 @@
const MAX_CHAT_MSGS = 24;
const TAB_BUTTON_ID = 'maintab_assistent';
const GEN_ID = 'generate';
- const MAX_REF_SLOTS = 4;
- const CONTEXT_PROMPT_MAX = 2000;
+ let MAX_REF_SLOTS = 4;
+ let CONTEXT_PROMPT_MAX = 2000;
+ let HISTORY_KEEP_TURNS = 4;
let ASPECT_TABLE = {
'1:1': [1024, 1024],
@@ -155,8 +156,18 @@
restoringChat: false,
chatsPanelOpen: false,
slashIndex: 0,
+ llmParked: false,
+ memoryRows: [],
+ wanted: { count: 0, items: [] },
+ wantedKeys: new Set(),
+ ollamaHealth: 'unknown',
};
+ /** Disk persistence module (assistent.persist.js) — absent means localStorage only. */
+ function diskPersist() {
+ return (window.SA && window.SA.persist) || null;
+ }
+
function $(id) {
return document.getElementById(id);
}
@@ -215,6 +226,7 @@
thinking: 'Thinking…',
streaming: 'Writing…',
generating: 'Generating image…',
+ parking: 'Освобождаю VRAM (park LLM)…',
applying: 'Applying patch…',
silent_gen: 'Применяю патч → Generate…',
refining: 'Civitai search done — refining…',
@@ -676,6 +688,7 @@
const tab = document.getElementById(TAB_BUTTON_ID);
if (tab) {
tab.click();
+ setTimeout(() => $('sa_input')?.focus(), 50);
return true;
}
const pane = document.getElementById('assistent');
@@ -684,9 +697,15 @@
bootstrap.Tab.getOrCreateInstance(tab || pane).show();
} catch (e) { /* ignore */ }
}
+ setTimeout(() => $('sa_input')?.focus(), 50);
return !!tab;
}
+ function historyMessageLimit() {
+ const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
+ return turns * 2;
+ }
+
function flashImagePane(slotId) {
const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
if (!el) {
@@ -1540,6 +1559,7 @@
}));
state.chats = chats;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
+ saveActiveChatToDisk();
} catch (e) {
console.warn('Assistent: persist chats failed', e);
try {
@@ -1550,19 +1570,33 @@
.slice(0, 12)
.map((c) => ({
...c,
- messages: slimHistoryMessages(c.messages).slice(-12).map((m) => ({
+ messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
...m,
content: String(m.content || '').slice(0, 1500),
})),
}));
state.chats = slim;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
+ saveActiveChatToDisk();
} catch (e2) {
console.warn('Assistent: chats quota fallback failed', e2);
}
}
}
+ /** Mirrors the active chat onto the data volume (debounced inside SA.persist). */
+ function saveActiveChatToDisk() {
+ const persist = diskPersist();
+ if (!persist || !state.activeChatId) {
+ return;
+ }
+ const chat = findChat(state.activeChatId);
+ if (!chat || !(chat.messages || []).length) {
+ return;
+ }
+ persist.saveChat(chat);
+ }
+
function loadChatsStore() {
state.chats = [];
try {
@@ -1577,6 +1611,28 @@
migrateLegacyHistoryIntoChats();
}
+ /** Disk wins over localStorage — chats follow the data volume, not the browser. */
+ async function loadChatsFromDisk() {
+ const persist = diskPersist();
+ if (!persist) {
+ return;
+ }
+ let chats = null;
+ try {
+ chats = await persist.loadChats();
+ } catch (e) {
+ console.warn('Assistent: disk chats failed', e);
+ return;
+ }
+ if (!Array.isArray(chats) || !chats.length) {
+ return;
+ }
+ state.chats = chats.filter((c) => c && c.id).slice(0, MAX_CHATS);
+ try {
+ localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: state.chats }));
+ } catch (e) { /* quota — disk is the source of truth anyway */ }
+ }
+
function migrateLegacyHistoryIntoChats() {
try {
const raw = localStorage.getItem(LS_HISTORY);
@@ -1862,6 +1918,7 @@
if (wasActive) {
state.activeChatId = null;
}
+ diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
persistChatsStore();
syncHistoryBadge();
if (wasActive) {
@@ -1871,11 +1928,13 @@
}
}
- function initChatSessions() {
+ async function initChatSessions() {
loadChatsStore();
+ await loadChatsFromDisk();
// Always open a fresh chat on startup; past chats stay in History.
startNewChat({ saveCurrent: false, force: true });
syncHistoryBadge();
+ renderChatsList();
}
function persistHistory() {
@@ -2588,51 +2647,21 @@
saveTaste();
}
+ // Fallback key list — only used if assistent.patch.js failed to load.
+ const FALLBACK_PATCH_KEYS = [
+ 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
+ 'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise',
+ 'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack',
+ ];
+
function isPatchObject(obj) {
+ if (window.SA && typeof SA.isPatchObject === 'function') {
+ return SA.isPatchObject(obj);
+ }
if (!obj || typeof obj !== 'object') {
return false;
}
- return (
- obj.prompt != null ||
- obj.loras ||
- obj.width ||
- obj.height ||
- obj.steps ||
- obj.cfg ||
- obj.seed != null ||
- obj.sigma_shift != null ||
- obj.sampler ||
- obj.actions ||
- obj.search_query ||
- obj.civitai_query ||
- obj.use_init_image != null ||
- obj.clear_init_image != null ||
- obj.init_creativity != null ||
- obj.denoise != null ||
- obj.use_mask_image != null ||
- obj.clear_mask_image != null ||
- obj.mask_blur != null ||
- obj.mask_grow != null ||
- obj.look_at != null ||
- obj.vision_from != null ||
- obj.vision_slots != null ||
- obj.slot_to_init != null ||
- obj.slot_to_mask != null ||
- obj.snapshot_generate != null ||
- obj.select_slot != null ||
- obj.aspect != null ||
- obj.images != null ||
- obj.batch != null ||
- obj.vary != null ||
- obj.lock_seed != null ||
- obj.creativity != null ||
- obj.intensity != null ||
- obj.complexity != null ||
- obj.movement != null ||
- obj.clear_prompt_images != null ||
- obj.slot_to_prompt_image != null ||
- obj.pack != null
- );
+ return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
}
function isCardObject(obj) {
@@ -2676,6 +2705,9 @@
}
function extractPatch(text) {
+ if (window.SA && typeof SA.extractPatch === 'function') {
+ return SA.extractPatch(text);
+ }
if (!text) {
return { prose: text || '', patch: null };
}
@@ -2684,9 +2716,8 @@
let lastPatch = null;
let prose = text;
while ((match = re.exec(text)) !== null) {
- const raw = match[1].trim();
try {
- const obj = JSON.parse(raw);
+ const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) {
lastPatch = obj;
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
@@ -3169,6 +3200,42 @@
return false;
}
+ /** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */
+ function parkLlm() {
+ return new Promise((resolve) => {
+ const model = $('sa_model')?.value;
+ if (!model || state.llmParked || typeof genericRequest !== 'function') {
+ resolve(false);
+ return;
+ }
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ let settled = false;
+ const finish = (ok) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ if (ok) {
+ state.llmParked = true;
+ }
+ resolve(!!ok);
+ };
+ setTimeout(() => finish(false), 8000);
+ genericRequest('AssistentParkLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
+ });
+ }
+
+ /** Fire-and-forget re-load of the chat model once the user is back in the chat. */
+ function warmLlm() {
+ const model = $('sa_model')?.value;
+ if (!model || !state.llmParked || typeof genericRequest !== 'function') {
+ return;
+ }
+ state.llmParked = false;
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ genericRequest('AssistentWarmLlm', { baseUrl, model }, () => {}, 0, () => {});
+ }
+
function cancelWaitForNewImage() {
if (state.waitImageTimer) {
clearInterval(state.waitImageTimer);
@@ -3259,6 +3326,9 @@
return null;
}
const prev = findCurrentGenerateSrc();
+ startBusyUi('parking');
+ setStatus('Освобождаю VRAM…');
+ await parkLlm();
setStatus('Генерация…');
startBusyUi('generating');
state.generating = true;
@@ -3276,6 +3346,9 @@
if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
}
+ if (state.view === 'chat') {
+ warmLlm();
+ }
if (src) {
const gen = generateSlot();
if (gen) {
@@ -3291,31 +3364,77 @@
return null;
}
+ /** Resolves the freshest real Generate frame — never a model preview. */
+ async function resolveFinishedGenerateSrc(hint, { settleMs = 20000 } = {}) {
+ scrubPreviewFromGenerateSlot();
+ let src = hint && !looksLikeModelPreview(hint) ? hint : null;
+ if (!src) {
+ src = findCurrentGenerateSrc();
+ }
+ // Batch still running: the last frame is not the final one yet.
+ if (isGenerateUnavailable()) {
+ const settled = await waitForNewImage(src, settleMs);
+ if (settled) {
+ src = settled;
+ }
+ }
+ return src && !looksLikeModelPreview(src) ? src : null;
+ }
+
async function maybeAutoCritique(imageSrc) {
- if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed || !imageSrc) {
+ if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed) {
+ return;
+ }
+ const src = await resolveFinishedGenerateSrc(imageSrc);
+ if (!src) {
+ setStatus('Авто-критика пропущена — нет готового кадра Generate');
return;
}
state.critiqueHopUsed = true;
- const pack = $('sa_pack');
- if (pack) {
- pack.value = 'critique_image';
- saveSettings();
- }
+ setPackValue('critique_image', { flash: true });
if ($('sa_input')) {
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
}
const gen = generateSlot();
if (gen) {
gen.attach = true;
- if (imageSrc) {
- gen.src = imageSrc;
- }
+ gen.src = src;
renderBoard();
}
setStatus('Auto-critique…');
await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
}
+ /** Board action: attach the finished Generate frame and ask for a verdict. */
+ async function askLookAtResult() {
+ if (state.busy || state.generating) {
+ setStatus('Занято — дождись конца ответа или Стоп');
+ return;
+ }
+ if (!updateGate()) {
+ setStatus('Выбери модель Krea 2');
+ return;
+ }
+ const src = await resolveFinishedGenerateSrc(generateSlot()?.src, { settleMs: 8000 });
+ if (!src) {
+ setStatus('Нет готового кадра Generate — сначала сгенерируй');
+ return;
+ }
+ const gen = generateSlot();
+ if (gen) {
+ gen.src = src;
+ gen.attach = true;
+ }
+ setBoardTab('generate');
+ renderBoard();
+ setView('chat');
+ setPackValue('critique_image', { flash: true });
+ if ($('sa_input')) {
+ $('sa_input').value = 'Посмотри результат: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.';
+ }
+ await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true });
+ }
+
function currentPersonaInfo() {
const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
const known = (state.personas || []).find((p) => p && p.id === id);
@@ -3505,18 +3624,18 @@
actions.className = 'sa-civitai-actions';
if (r.already_installed) {
const note = document.createElement('span');
- note.textContent = 'Installed';
+ note.textContent = 'Уже установлена';
actions.appendChild(note);
} else if (r.download_url) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'basic-button sa-primary';
- btn.textContent = 'Confirm download';
+ btn.textContent = 'Подтвердить скачивание';
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
actions.appendChild(btn);
} else {
const note = document.createElement('span');
- note.textContent = 'No download URL';
+ note.textContent = 'Нет URL скачивания';
actions.appendChild(note);
}
card.appendChild(actions);
@@ -3531,9 +3650,9 @@
}
if (btn) {
btn.disabled = true;
- btn.textContent = 'Downloading…';
+ btn.textContent = 'Скачиваю…';
}
- setStatus(`Downloading ${card.file_name || card.name}…`);
+ setStatus(`Скачиваю ${card.file_name || card.name}…`);
setInterruptVisible(true);
const payload = {
url: card.download_url,
@@ -3543,9 +3662,9 @@
const onDone = (ok, msg) => {
setInterruptVisible(state.busy || state.generating);
if (ok) {
- setStatus(`Downloaded ${payload.name}`);
+ setStatus(`Скачано ${payload.name}`);
if (btn) {
- btn.textContent = 'Downloaded';
+ btn.textContent = 'Скачано';
}
refreshInventory(async () => {
await maybeWriteCardAfterDownload({
@@ -3555,12 +3674,12 @@
});
}, { rescan: true });
} else {
- setStatus(msg || 'Download failed');
+ setStatus(msg || 'Ошибка скачивания');
if (btn) {
btn.disabled = false;
- btn.textContent = 'Confirm download';
+ btn.textContent = 'Подтвердить скачивание';
}
- appendMessage('error', msg || 'Download failed');
+ appendMessage('error', msg || 'Ошибка скачивания');
}
};
if (typeof makeWSRequest === 'function') {
@@ -3858,6 +3977,88 @@
}
}
+ function collectUiState() {
+ return {
+ pack: $('sa_pack')?.value || 'write_prompt',
+ persona: $('sa_persona')?.value || 'neutral',
+ auto_vision: !!$('sa_auto_vision')?.checked,
+ auto_apply: !!$('sa_auto_apply')?.checked,
+ auto_generate: !!$('sa_auto_generate')?.checked,
+ auto_critique: !!$('sa_auto_critique')?.checked,
+ auto_download: !!$('sa_auto_download')?.checked,
+ pane_width: localStorage.getItem(LS_PANE_WIDTH) || '',
+ embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
+ base_url: $('sa_base_url')?.value || '',
+ model: $('sa_model')?.value || '',
+ view: state.view || 'chat',
+ board_tab: state.boardTab || 'generate',
+ };
+ }
+
+ /**
+ * Fills fields the browser has never seen from Assistent/ui-state.json, so a fresh
+ * browser on the same volume inherits the previous session. Existing localStorage wins.
+ * auto_download is only ever restored when it is off — the danger flag stays opt-in.
+ */
+ async function applyDiskUiState() {
+ const persist = diskPersist();
+ if (!persist) {
+ return;
+ }
+ let ui = null;
+ try {
+ ui = await persist.loadUiState();
+ } catch (e) {
+ return;
+ }
+ if (!ui || typeof ui !== 'object') {
+ return;
+ }
+ const fill = (lsKey, value, apply) => {
+ if (value == null || value === '' || localStorage.getItem(lsKey) != null) {
+ return;
+ }
+ localStorage.setItem(lsKey, String(value));
+ apply?.(String(value));
+ };
+ fill(LS_BASE, ui.base_url, (v) => { if ($('sa_base_url')) { $('sa_base_url').value = v; } });
+ fill(LS_MODEL, ui.model, (v) => { state.preferredModel = v; });
+ fill(LS_EMBED, ui.embed_model, (v) => { state.preferredEmbed = v; });
+ fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
+ fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
+ fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
+ if (ui.view === 'cards' || ui.view === 'chat') {
+ fill(LS_VIEW, ui.view, (v) => { state.view = v; });
+ }
+ if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
+ fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
+ }
+ for (const [key, lsKey, id] of [
+ ['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
+ ['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
+ ['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'],
+ ['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'],
+ ['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'],
+ ]) {
+ if (ui[key] == null || localStorage.getItem(lsKey) != null) {
+ continue;
+ }
+ const on = ui[key] === true || ui[key] === '1' || ui[key] === 1;
+ if (on && key === 'auto_download') {
+ continue;
+ }
+ localStorage.setItem(lsKey, on ? '1' : '0');
+ const el = $(id);
+ if (el) {
+ el.checked = on;
+ }
+ }
+ }
+
+ function saveUiStateToDisk() {
+ diskPersist()?.saveUiState(collectUiState());
+ }
+
function saveSettings() {
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
@@ -3871,6 +4072,7 @@
localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
persistServerSettings();
+ saveUiStateToDisk();
}
function persistServerSettings() {
@@ -3936,6 +4138,16 @@
if (data.assistant?.embed_model && !state.preferredEmbed) {
state.preferredEmbed = data.assistant.embed_model;
}
+ const asst = data.assistant || {};
+ if (asst.history_keep_turns != null) {
+ HISTORY_KEEP_TURNS = Math.max(1, Number(asst.history_keep_turns) || 4);
+ }
+ if (asst.max_ref_slots != null) {
+ MAX_REF_SLOTS = Math.max(1, Number(asst.max_ref_slots) || 4);
+ }
+ if (asst.context_prompt_max != null) {
+ CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
+ }
if (applyDefaults || data.exact) {
fillEmptyParamsFromExact();
}
@@ -4147,6 +4359,11 @@
$('sa_model').value = prefer;
}
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
+ if (models.length) {
+ setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`);
+ } else {
+ setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
+ }
saveSettings();
},
0,
@@ -4154,6 +4371,7 @@
const msg = String(err || 'Ollama unreachable');
setStatus(msg);
setModelOptions([], { error: msg });
+ setOllamaHealth('down', 'Ollama ✕', msg);
appendMessage('error', msg);
},
);
@@ -4205,6 +4423,224 @@
return new Promise((resolve) => refreshInventory(resolve, opts));
}
+ function memoryKindFilter() {
+ return $('sa_mem_kind')?.value || 'all';
+ }
+
+ function renderMemoryKinds(kinds) {
+ const sel = $('sa_mem_kind');
+ if (!sel) {
+ return;
+ }
+ const cur = sel.value || 'all';
+ sel.innerHTML = '';
+ const all = document.createElement('option');
+ all.value = 'all';
+ all.textContent = 'Все типы';
+ sel.appendChild(all);
+ for (const kind of kinds || []) {
+ const opt = document.createElement('option');
+ opt.value = kind;
+ opt.textContent = kind;
+ sel.appendChild(opt);
+ }
+ if ([...sel.options].some((o) => o.value === cur)) {
+ sel.value = cur;
+ }
+ }
+
+ function renderMemoryList() {
+ const root = $('sa_mem_list');
+ if (!root) {
+ return;
+ }
+ const filter = memoryKindFilter();
+ const rows = (state.memoryRows || []).filter((m) => filter === 'all' || m.kind === filter);
+ root.innerHTML = '';
+ if (!rows.length) {
+ root.innerHTML = '
Память пуста — она наполняется из карточек, seed и патчей memory_upsert.
';
+ return;
+ }
+ for (const row of rows) {
+ const el = document.createElement('div');
+ el.className = 'sa-mem-row';
+ const bundled = row.source === 'bundled';
+ const when = row.updated ? formatChatWhen(row.updated * 1000) : '';
+ const scope = row.scope === 'personal' ? `персона ${row.persona || '—'}` : 'общая';
+ el.innerHTML = `${escapeHtml(row.kind || 'note')}${escapeHtml(row.key || '')}
${escapeHtml(clipDebug(row.text, 220))}
${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}
`;
+ const forget = document.createElement('button');
+ forget.type = 'button';
+ forget.className = 'basic-button sa-mem-forget';
+ forget.textContent = '×';
+ if (bundled) {
+ forget.disabled = true;
+ forget.title = 'Bundled — вернётся при reseed, правь Config/_base/memory-seed/';
+ } else {
+ forget.title = 'Забыть';
+ forget.addEventListener('click', () => forgetMemory(row));
+ }
+ el.appendChild(forget);
+ root.appendChild(el);
+ }
+ }
+
+ function refreshMemoryList() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const list = $('sa_mem_list');
+ if (list && !state.memoryRows.length) {
+ list.innerHTML = 'Читаю память…
';
+ }
+ genericRequest(
+ 'AssistentListMemory',
+ { limit: 200 },
+ (data) => {
+ state.memoryRows = Array.isArray(data?.memories) ? data.memories : [];
+ renderMemoryKinds(data?.kinds || []);
+ renderMemoryList();
+ const foot = $('sa_mem_total');
+ if (foot) {
+ foot.textContent = `Всего: ${data?.total ?? state.memoryRows.length} · ${data?.embed_model || '—'}`;
+ }
+ },
+ 0,
+ (err) => {
+ if (list) {
+ list.innerHTML = `Память недоступна: ${escapeHtml(String(err || 'ошибка'))}
`;
+ }
+ },
+ );
+ }
+
+ function forgetMemory(row) {
+ if (!row?.kind || !row?.key || typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentForgetMemory',
+ {
+ kind: row.kind,
+ key: row.key,
+ source: row.source || '',
+ scope: row.scope || '',
+ persona: row.scope === 'personal' ? (row.persona || '') : '',
+ },
+ () => {
+ state.memoryRows = (state.memoryRows || []).filter((m) => !(m.kind === row.kind && m.key === row.key && m.source === row.source && m.persona === row.persona));
+ renderMemoryList();
+ setStatus(`Забыто: ${row.kind}/${row.key}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Не удалось забыть')),
+ );
+ }
+
+ function modelKeyLeaf(name) {
+ return String(name || '')
+ .replace(/\\/g, '/')
+ .split('/')
+ .pop()
+ .replace(/\.(safetensors|ckpt|pt|pth|gguf|bin)$/i, '')
+ .trim()
+ .toLowerCase();
+ }
+
+ function refreshWantedQueue() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentListWanted',
+ {},
+ (data) => {
+ const items = Array.isArray(data?.items) ? data.items : [];
+ state.wanted = { count: data?.count ?? items.length, items };
+ const keys = new Set();
+ for (const item of items) {
+ const leaf = modelKeyLeaf(item?.title);
+ if (leaf) {
+ keys.add(leaf);
+ }
+ if (item?.version_id) {
+ keys.add(`v${item.version_id}`);
+ }
+ }
+ state.wantedKeys = keys;
+ const el = $('sa_mem_wanted');
+ if (el) {
+ el.textContent = state.wanted.count
+ ? `Очередь wanted: ${state.wanted.count} (скачается на следующем up)`
+ : 'Очередь wanted: пусто';
+ el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join('\n');
+ }
+ if (state.view === 'cards') {
+ renderCardsList();
+ }
+ },
+ 0,
+ () => {
+ const el = $('sa_mem_wanted');
+ if (el) {
+ el.textContent = 'Очередь wanted: —';
+ }
+ },
+ );
+ }
+
+ function isWantedModel(row) {
+ const keys = state.wantedKeys;
+ if (!keys || !keys.size) {
+ return false;
+ }
+ for (const candidate of [row?.name, row?.title]) {
+ const leaf = modelKeyLeaf(candidate);
+ if (leaf && keys.has(leaf)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function setOllamaHealth(level, text, title) {
+ state.ollamaHealth = level;
+ const el = $('sa_ollama_health');
+ if (!el) {
+ return;
+ }
+ el.hidden = false;
+ el.textContent = text;
+ el.title = title || text;
+ el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
+ el.classList.add(`sa-health-${level}`);
+ }
+
+ function probeOllamaHealth() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
+ genericRequest(
+ 'AssistentListModels',
+ { baseUrl },
+ (data) => {
+ if (data?.error) {
+ setOllamaHealth('down', 'Ollama ✕', String(data.error));
+ return;
+ }
+ const chat = (data.models || []).length;
+ const mem = (data.memory_models || []).length;
+ if (!chat) {
+ setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
+ return;
+ }
+ setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
+ },
+ 0,
+ (err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`),
+ );
+ }
+
function setCardStatus(msg) {
const el = $('sa_card_status');
if (el) {
@@ -4227,6 +4663,9 @@
saveSettings();
if (state.view === 'cards') {
renderCardsList();
+ } else if (state.llmParked && !state.generating) {
+ // Back in the chat — bring the model home before the user hits Send.
+ warmLlm();
}
}
@@ -4366,6 +4805,10 @@
if (row.has_sidecar) {
metaBits.push('sidecar');
}
+ if (isWantedModel(row)) {
+ metaBits.push('⏳ wanted');
+ btn.classList.add('sa-card-row-wanted');
+ }
if (row.trigger) {
metaBits.push(String(row.trigger).slice(0, 40));
}
@@ -4863,7 +5306,10 @@
}
const remoteUpdated = remote.updated || 0;
const localUpdated = state.taste?.updated || 0;
- if (remoteUpdated >= localUpdated) {
+ // Disk taste.json is the source of truth; localStorage only wins when it is strictly newer.
+ const localEmpty = !localUpdated
+ && !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
+ if (localEmpty || remoteUpdated >= localUpdated) {
state.taste = {
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
@@ -5157,7 +5603,7 @@
} else {
why.push('последнего патча Assistent ещё нет');
}
- why.push('приоритет: user → session_exact → exact(+persona) → live UI → memory_hits');
+ why.push('приоритет: user → session_exact → exact(+persona) → live UI → memory_hits (shared+personal)');
const lines = [
'### Debug Assistent',
@@ -5481,6 +5927,8 @@
const chatEpoch = bumpChatEpoch();
state.busy = true;
+ // Ollama reloads the model for this request (keep_alive 15m), so it is no longer parked.
+ state.llmParked = false;
setInterruptVisible(true);
startBusyUi('thinking');
saveSettings();
@@ -5565,7 +6013,7 @@
// 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(-historyMessageLimit()).map((m) => ({ role: m.role, content: m.content }));
if (images && messages.length) {
messages[messages.length - 1].images = images;
}
@@ -5584,16 +6032,6 @@
context_json: JSON.stringify(context),
skills: state.enabledSkills || [],
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
- raw: {
- messages,
- context_json: JSON.stringify(context),
- pack,
- persona,
- base_url: baseUrl,
- model,
- skills: state.enabledSkills || [],
- embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
- },
};
const finishOk = async (reply, civitaiResults) => {
@@ -5870,6 +6308,29 @@
);
}
+ /** Disk state first (chats + UI prefs), then config / models / inventory. */
+ async function bootstrapPersisted() {
+ try {
+ await applyDiskUiState();
+ } catch (e) {
+ console.warn('Assistent: ui-state restore failed', e);
+ }
+ try {
+ await initChatSessions();
+ } catch (e) {
+ console.warn('Assistent: chat sessions failed', e);
+ }
+ loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
+ refreshModels();
+ refreshInventory(() => {
+ renderCardsList();
+ renderLoraChips();
+ });
+ });
+ probeOllamaHealth();
+ refreshWantedQueue();
+ }
+
function wire() {
if (!$('swarm_assistent_root')) {
return;
@@ -5893,14 +6354,7 @@
if (wantsAutoVision()) {
refreshImagePreview();
}
- initChatSessions();
- loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
- refreshModels();
- refreshInventory(() => {
- renderCardsList();
- renderLoraChips();
- });
- });
+ bootstrapPersisted();
wireDropZone();
wireSplitter();
registerSendButton();
@@ -5957,11 +6411,51 @@
const s = $('sa_settings');
if (s) {
s.hidden = !s.hidden;
+ if (!s.hidden) {
+ refreshMemoryList();
+ refreshWantedQueue();
+ }
}
});
+ $('sa_btn_mem_refresh')?.addEventListener('click', () => {
+ refreshMemoryList();
+ refreshWantedQueue();
+ });
+ $('sa_mem_kind')?.addEventListener('change', renderMemoryList);
+ $('sa_btn_look_result')?.addEventListener('click', () => askLookAtResult());
+ $('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
+ document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape') {
+ return;
+ }
+ let closed = false;
+ const settings = $('sa_settings');
+ if (settings && !settings.hidden) {
+ settings.hidden = true;
+ closed = true;
+ }
+ if (state.chatsPanelOpen) {
+ setChatsPanelOpen(false);
+ closed = true;
+ }
+ const slash = $('sa_slash_menu');
+ if (slash && !slash.hidden) {
+ slash.hidden = true;
+ closed = true;
+ }
+ closeAllMoreMenus();
+ if (closed) {
+ e.preventDefault();
+ }
+ });
+ // Focus composer when Assistent tab becomes visible
+ document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => {
+ setTimeout(() => $('sa_input')?.focus(), 80);
+ });
$('sa_btn_refresh_models')?.addEventListener('click', () => {
saveSettings();
refreshModels();
+ probeOllamaHealth();
});
$('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
renderCardsList();
@@ -6104,9 +6598,24 @@
setInterval(updateGate, 2000);
setInterval(syncGenerateSlot, 700);
+ setInterval(() => {
+ if (!state.busy && !state.generating) {
+ probeOllamaHealth();
+ }
+ }, 45000);
+ setInterval(() => {
+ if (!state.busy && !state.generating) {
+ refreshWantedQueue();
+ }
+ }, 120000);
window.addEventListener('beforeunload', () => {
try {
saveActiveChatToStore({ dropEmpty: true });
+ const chat = findChat(state.activeChatId);
+ if (chat && (chat.messages || []).length) {
+ diskPersist()?.saveChat(chat, { immediate: true });
+ }
+ diskPersist()?.saveUiState(collectUiState(), { immediate: true });
} catch (e) { /* ignore */ }
});
setInterval(() => {
diff --git a/Assets/assistent.patch.js b/Assets/assistent.patch.js
new file mode 100644
index 0000000..0e84779
--- /dev/null
+++ b/Assets/assistent.patch.js
@@ -0,0 +1,79 @@
+/**
+ * Swarm Assistent — patch detection / extraction / alias normalization.
+ * Loaded before assistent.js; mirrors AssistentPatch.cs on the server side.
+ */
+window.SA = window.SA || {};
+
+(function () {
+ const PATCH_KEYS = [
+ 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
+ 'actions', 'search_query', 'civitai_query',
+ 'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
+ 'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
+ 'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
+ 'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
+ 'creativity', 'intensity', 'complexity', 'movement',
+ 'clear_prompt_images', 'slot_to_prompt_image', 'pack',
+ ];
+
+ const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
+
+ function has(obj, key) {
+ return obj[key] !== undefined && obj[key] !== null;
+ }
+
+ /** True when the object looks like a generation patch rather than arbitrary JSON. */
+ function isPatchObject(obj) {
+ if (!obj || typeof obj !== 'object') {
+ return false;
+ }
+ return PATCH_KEYS.some((k) => has(obj, k));
+ }
+
+ /** Maps alias fields onto canonical names, keeping the aliases in place. */
+ function normalizePatch(patch) {
+ if (!patch || typeof patch !== 'object') {
+ return patch;
+ }
+ if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
+ patch.search_query = patch.civitai_query;
+ }
+ if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
+ patch.init_creativity = patch.denoise;
+ }
+ if (!has(patch, 'look_at')) {
+ if (has(patch, 'vision_from')) {
+ patch.look_at = patch.vision_from;
+ } else if (has(patch, 'vision_slots')) {
+ patch.look_at = patch.vision_slots;
+ }
+ }
+ return patch;
+ }
+
+ /** Splits a reply into prose and the last fenced patch object found in it. */
+ function extractPatch(text) {
+ if (!text) {
+ return { prose: text || '', patch: null };
+ }
+ const re = new RegExp(FENCE_RE.source, 'gi');
+ let match;
+ let lastPatch = null;
+ let prose = text;
+ while ((match = re.exec(text)) !== null) {
+ try {
+ const obj = JSON.parse(match[1].trim());
+ if (isPatchObject(obj)) {
+ lastPatch = normalizePatch(obj);
+ prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
+ }
+ } catch (e) { /* not json */ }
+ }
+ return { prose, patch: lastPatch };
+ }
+
+ SA.PATCH_KEYS = PATCH_KEYS;
+ SA.isPatchObject = isPatchObject;
+ SA.normalizePatch = normalizePatch;
+ SA.extractPatch = extractPatch;
+})();
diff --git a/Assets/assistent.persist.js b/Assets/assistent.persist.js
new file mode 100644
index 0000000..e17ab10
--- /dev/null
+++ b/Assets/assistent.persist.js
@@ -0,0 +1,191 @@
+/**
+ * Swarm Assistent — disk persistence for chats + UI state (Assistent/chats/, Assistent/ui-state.json).
+ * Loaded after assistent.api.js and before assistent.js.
+ */
+window.SA = window.SA || {};
+
+(function () {
+ const LS_CHATS = 'swarm_assistent_chats_v1';
+ const LS_MIGRATED = 'swarm_assistent_chats_on_disk_v1';
+ const SAVE_DEBOUNCE_MS = 700;
+
+ const timers = { chats: new Map(), ui: null };
+
+ function request(name, body) {
+ if (typeof SA.request === 'function') {
+ return SA.request(name, body);
+ }
+ return new Promise((resolve, reject) => {
+ if (typeof genericRequest !== 'function') {
+ reject(new Error('genericRequest unavailable'));
+ return;
+ }
+ genericRequest(
+ name,
+ body || {},
+ (data) => (data && data.error ? reject(new Error(String(data.error))) : resolve(data)),
+ 0,
+ (err) => reject(err instanceof Error ? err : new Error(String(err || 'request failed'))),
+ );
+ });
+ }
+
+ function normalizeChat(raw) {
+ if (!raw || !raw.id) {
+ return null;
+ }
+ return {
+ id: String(raw.id),
+ title: String(raw.title || 'Новый чат'),
+ createdAt: Number(raw.createdAt) || Date.now(),
+ updatedAt: Number(raw.updatedAt) || Number(raw.createdAt) || Date.now(),
+ messages: Array.isArray(raw.messages) ? raw.messages : [],
+ params: raw.params && typeof raw.params === 'object' ? raw.params : null,
+ };
+ }
+
+ function readLocalChats() {
+ try {
+ const parsed = JSON.parse(localStorage.getItem(LS_CHATS) || 'null');
+ if (Array.isArray(parsed?.chats)) {
+ return parsed.chats.map(normalizeChat).filter(Boolean);
+ }
+ } catch (e) { /* ignore */ }
+ return [];
+ }
+
+ /** One-shot lift of the browser-only history onto the data volume. */
+ async function migrateLocalChatsToDisk() {
+ if (localStorage.getItem(LS_MIGRATED) === '1') {
+ return [];
+ }
+ const local = readLocalChats().filter((c) => (c.messages || []).length > 0);
+ localStorage.setItem(LS_MIGRATED, '1');
+ if (!local.length) {
+ return [];
+ }
+ for (const chat of local) {
+ try {
+ await saveChat(chat, { immediate: true });
+ } catch (e) {
+ console.warn('Assistent: chat migration failed', chat.id, e);
+ }
+ }
+ return local;
+ }
+
+ /** Disk chats, newest first. Falls back to a localStorage migration when the volume is empty. */
+ async function loadChats() {
+ let chats = [];
+ try {
+ const data = await request('AssistentListChats', { with_messages: true });
+ chats = (data?.chats || []).map(normalizeChat).filter(Boolean);
+ } catch (e) {
+ console.warn('Assistent: disk chats unavailable', e);
+ return null;
+ }
+ if (!chats.length) {
+ const migrated = await migrateLocalChatsToDisk();
+ if (migrated.length) {
+ chats = migrated;
+ }
+ } else {
+ localStorage.setItem(LS_MIGRATED, '1');
+ }
+ return chats.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
+ }
+
+ async function getChat(id) {
+ if (!id) {
+ return null;
+ }
+ const data = await request('AssistentGetChat', { id });
+ return normalizeChat(data?.chat);
+ }
+
+ function saveChat(chat, { immediate = false } = {}) {
+ const clean = normalizeChat(chat);
+ if (!clean) {
+ return Promise.resolve(null);
+ }
+ const send = () => {
+ timers.chats.delete(clean.id);
+ return request('AssistentSaveChat', {
+ id: clean.id,
+ title: clean.title,
+ messages: clean.messages,
+ params: clean.params,
+ createdAt: clean.createdAt,
+ updatedAt: clean.updatedAt,
+ });
+ };
+ if (immediate) {
+ const pending = timers.chats.get(clean.id);
+ if (pending) {
+ clearTimeout(pending);
+ }
+ return send();
+ }
+ const pending = timers.chats.get(clean.id);
+ if (pending) {
+ clearTimeout(pending);
+ }
+ timers.chats.set(clean.id, setTimeout(() => {
+ send().catch((e) => console.warn('Assistent: chat save failed', clean.id, e));
+ }, SAVE_DEBOUNCE_MS));
+ return Promise.resolve(null);
+ }
+
+ function deleteChat(id) {
+ if (!id) {
+ return Promise.resolve(null);
+ }
+ const pending = timers.chats.get(id);
+ if (pending) {
+ clearTimeout(pending);
+ timers.chats.delete(id);
+ }
+ return request('AssistentDeleteChat', { id });
+ }
+
+ async function loadUiState() {
+ try {
+ const data = await request('AssistentGetUiState', {});
+ const ui = data?.ui_state;
+ return ui && typeof ui === 'object' ? ui : null;
+ } catch (e) {
+ return null;
+ }
+ }
+
+ function saveUiState(uiState, { immediate = false } = {}) {
+ if (!uiState || typeof uiState !== 'object') {
+ return Promise.resolve(null);
+ }
+ const send = () => {
+ timers.ui = null;
+ return request('AssistentSaveUiState', { ui_state: uiState });
+ };
+ if (timers.ui) {
+ clearTimeout(timers.ui);
+ timers.ui = null;
+ }
+ if (immediate) {
+ return send();
+ }
+ timers.ui = setTimeout(() => {
+ send().catch((e) => console.warn('Assistent: ui-state save failed', e));
+ }, SAVE_DEBOUNCE_MS);
+ return Promise.resolve(null);
+ }
+
+ SA.persist = {
+ LS_CHATS,
+ loadChats,
+ getChat,
+ saveChat,
+ deleteChat,
+ loadUiState,
+ saveUiState,
+ };
+})();
diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs
new file mode 100644
index 0000000..271d033
--- /dev/null
+++ b/AssistentChatPipeline.cs
@@ -0,0 +1,385 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Utils;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Prompt assembly, memory retrieval/writeback and the Civitai search hop loop.
+public partial class SwarmAssistentExtension
+{
+ const int MaxCivitaiHopsFallback = 2;
+
+ List BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable skillIds = null)
+ {
+ List ollamaMessages = [];
+ StringBuilder system = new();
+ string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
+
+ if (includeBase)
+ {
+ string core = Config.LoadCorePrompt(pid);
+ if (!string.IsNullOrWhiteSpace(core))
+ {
+ system.AppendLine(core);
+ }
+ }
+
+ JObject exact = Config.LoadExactForPrompt(pid);
+ if (exact is not null && exact.Count > 0)
+ {
+ system.AppendLine();
+ system.AppendLine("## Exact memory (canonical KV defaults — prefer over RAG for numbers)");
+ system.AppendLine("```json");
+ system.AppendLine(exact.ToString(Newtonsoft.Json.Formatting.None));
+ system.AppendLine("```");
+ }
+
+ foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
+ {
+ string skillText = Config.LoadSkillPrompt(pid, skillId);
+ if (!string.IsNullOrWhiteSpace(skillText))
+ {
+ system.AppendLine();
+ system.AppendLine($"## Skill: {skillId}");
+ system.AppendLine(skillText);
+ }
+ }
+
+ string identity = Config.RenderIdentityBlock(pid);
+ if (!string.IsNullOrWhiteSpace(identity))
+ {
+ system.AppendLine();
+ system.AppendLine(identity);
+ }
+
+ if (!string.IsNullOrWhiteSpace(packName))
+ {
+ string situational = Config.LoadPackPrompt(pid, packName);
+ if (!string.IsNullOrWhiteSpace(situational))
+ {
+ system.AppendLine();
+ system.AppendLine($"## Active mode: {packName}");
+ system.AppendLine(situational);
+ }
+ }
+ if (!string.IsNullOrWhiteSpace(contextJson))
+ {
+ system.AppendLine();
+ system.AppendLine("## Live SwarmUI context (JSON — trust this over guesses)");
+ system.AppendLine("```json");
+ system.AppendLine(contextJson);
+ system.AppendLine("```");
+ }
+ if (!string.IsNullOrWhiteSpace(extraSystem))
+ {
+ system.AppendLine();
+ system.AppendLine(extraSystem);
+ }
+ if (system.Length > 0)
+ {
+ ollamaMessages.Add(new JObject
+ {
+ ["role"] = "system",
+ ["content"] = system.ToString(),
+ });
+ }
+ foreach (JToken msg in userMessages ?? [])
+ {
+ if (msg is not JObject mo)
+ {
+ continue;
+ }
+ JObject copy = new()
+ {
+ ["role"] = mo["role"]?.ToString() ?? "user",
+ ["content"] = mo["content"]?.ToString() ?? "",
+ };
+ if (mo["images"] is JArray images && images.Count > 0)
+ {
+ copy["images"] = images;
+ }
+ ollamaMessages.Add(copy);
+ }
+ return ollamaMessages;
+ }
+
+ async Task<(string reply, JObject raw, JArray civitaiResults)> RunChatWithHops(
+ Session session,
+ string root,
+ string modelName,
+ string packName,
+ bool includeBase,
+ string contextJson,
+ JArray userMessages,
+ Func onDelta = null,
+ Func onHopStart = null,
+ string personaId = null,
+ JArray skillIds = null,
+ string embedModel = null)
+ {
+ string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
+ List skills = Config.ResolveEnabledSkills(pid, skillIds);
+ string embed = string.IsNullOrWhiteSpace(embedModel)
+ ? (Config.LoadSettings()["embed_model"]?.ToString()
+ ?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
+ ?? "nomic-embed-text")
+ : embedModel;
+
+ try
+ {
+ await Memory.EnsureSeedAsync(root, Config, embed);
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"Assistent memory seed: {ex.Message}");
+ }
+
+ string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson);
+ JArray hits = [];
+ try
+ {
+ int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value() ?? 10;
+ hits = await Memory.RetrieveAsync(root, retrieveQuery, topK, embed, Config.PersonaExtendsChain(pid));
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"Assistent memory retrieve: {ex.Message}");
+ }
+
+ string enrichedContext = InjectMemoryHits(contextJson, hits);
+ List messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
+ JArray civitaiResults = [];
+ string reply = "";
+ JObject lastRaw = null;
+ int maxHops = CfgInt("max_civitai_hops", MaxCivitaiHopsFallback);
+ for (int hop = 0; hop < maxHops; hop++)
+ {
+ if (onHopStart is not null)
+ {
+ await onHopStart(hop);
+ }
+ (reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
+ JObject patch = TryParsePatch(reply);
+ await ApplyMemoryActions(root, patch, embed, pid);
+ if (hop + 1 >= maxHops || !WantsCivitaiSearch(patch))
+ {
+ break;
+ }
+ string query = ExtractSearchQuery(patch);
+ if (string.IsNullOrWhiteSpace(query))
+ {
+ break;
+ }
+ JObject search = await AssistentSearchCivitai(session, query, 8);
+ if (search["error"] is not null)
+ {
+ messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
+ messages.Add(new JObject
+ {
+ ["role"] = "user",
+ ["content"] = $"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.",
+ });
+ continue;
+ }
+ civitaiResults = search["results"] as JArray ?? [];
+ messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
+ messages.Add(new JObject
+ {
+ ["role"] = "user",
+ ["content"] =
+ "Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
+ "Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
+ "Omit search_civitai from actions unless you need a different query.\n```json\n" +
+ civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
+ });
+ }
+ return (reply, lastRaw, civitaiResults);
+ }
+
+ static string BuildRetrieveQuery(JArray userMessages, string contextJson)
+ {
+ StringBuilder sb = new();
+ if (!string.IsNullOrWhiteSpace(contextJson))
+ {
+ try
+ {
+ JObject ctx = JObject.Parse(contextJson);
+ string ckpt = ctx["checkpoint"]?.ToString() ?? ctx["current_model"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(ckpt))
+ {
+ sb.Append(ckpt).Append(' ');
+ }
+ if (ctx["enabled_loras"] is JArray en)
+ {
+ foreach (JToken t in en.Take(8))
+ {
+ string n = t?["name"]?.ToString() ?? t?.ToString();
+ if (!string.IsNullOrWhiteSpace(n))
+ {
+ sb.Append(n).Append(' ');
+ }
+ }
+ }
+ if (ctx["krea_profile"] != null)
+ {
+ sb.Append("krea ").Append(ctx["krea_profile"]).Append(' ');
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ foreach (JToken msg in (userMessages ?? []).Reverse().Take(2))
+ {
+ if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
+ {
+ sb.Append(mo["content"]?.ToString()).Append(' ');
+ }
+ }
+ string q = CollapseWs(sb.ToString());
+ return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q;
+ }
+
+ static string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
+ {
+ JObject ctx;
+ try
+ {
+ ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson);
+ }
+ catch
+ {
+ ctx = new JObject { ["_raw_context"] = contextJson };
+ }
+ ctx["memory_hits"] = hits ?? new JArray();
+ // Never re-inject full Exact into live context (already in system prompt).
+ ctx.Remove("exact");
+ if (ctx["session_exact"] is null)
+ {
+ ctx["session_exact"] = new JObject();
+ }
+ // Slim inventory for LLM: keep enabled + current, drop full dump if present
+ if (ctx["available_loras"] is JArray allLoras && allLoras.Count > 24)
+ {
+ HashSet keep = new(StringComparer.OrdinalIgnoreCase);
+ if (ctx["enabled_loras"] is JArray en)
+ {
+ foreach (JToken t in en)
+ {
+ string n = t?["name"]?.ToString() ?? t?.ToString();
+ if (!string.IsNullOrWhiteSpace(n))
+ {
+ keep.Add(n);
+ }
+ }
+ }
+ foreach (JToken hit in hits ?? [])
+ {
+ if (string.Equals(hit?["kind"]?.ToString(), "lora", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(hit?["kind"]?.ToString(), "card", StringComparison.OrdinalIgnoreCase))
+ {
+ string k = hit?["key"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(k))
+ {
+ keep.Add(k);
+ }
+ }
+ }
+ JArray slim = [];
+ foreach (JToken t in allLoras)
+ {
+ string n = t?["name"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(n) && (keep.Contains(n) || slim.Count < 12))
+ {
+ if (keep.Contains(n) || t?["krea_likely"]?.Value() == true)
+ {
+ slim.Add(t);
+ }
+ }
+ }
+ if (slim.Count == 0)
+ {
+ foreach (JToken t in allLoras.Take(12))
+ {
+ slim.Add(t);
+ }
+ }
+ ctx["available_loras"] = slim;
+ ctx["available_loras_truncated"] = true;
+ ctx["available_loras_total"] = allLoras.Count;
+ }
+ return ctx.ToString(Newtonsoft.Json.Formatting.None);
+ }
+
+ static string MemoryWritePersona(JObject mo, string currentPersonaId)
+ {
+ string scope = (mo?["scope"]?.ToString() ?? "").Trim().ToLowerInvariant();
+ if (scope is "shared" or "common" or "global")
+ {
+ return AssistentMemory.SharedPersona;
+ }
+ // Personal only — never let the model write into another personality's store.
+ return AssistentConfig.SafeId(currentPersonaId) ?? AssistentMemory.SharedPersona;
+ }
+
+ async Task ApplyMemoryActions(string root, JObject patch, string embedModel, string personaId)
+ {
+ if (patch is null || Memory is null)
+ {
+ return;
+ }
+ bool upsert = false, forget = false;
+ if (patch["actions"] is JArray acts)
+ {
+ foreach (JToken a in acts)
+ {
+ string s = a?.ToString() ?? "";
+ if (string.Equals(s, "memory_upsert", StringComparison.OrdinalIgnoreCase))
+ {
+ upsert = true;
+ }
+ if (string.Equals(s, "memory_forget", StringComparison.OrdinalIgnoreCase))
+ {
+ forget = true;
+ }
+ }
+ }
+ JArray memories = patch["memories"] as JArray;
+ if (memories is null || memories.Count == 0)
+ {
+ return;
+ }
+ foreach (JToken t in memories)
+ {
+ if (t is not JObject mo)
+ {
+ continue;
+ }
+ string kind = mo["kind"]?.ToString() ?? "note";
+ string key = mo["key"]?.ToString() ?? "";
+ string text = mo["text"]?.ToString() ?? "";
+ string target = MemoryWritePersona(mo, personaId);
+ try
+ {
+ if (forget && string.IsNullOrWhiteSpace(text))
+ {
+ Memory.Forget(kind, key, persona: target);
+ }
+ else if (upsert || !string.IsNullOrWhiteSpace(text))
+ {
+ await Memory.UpsertTextAsync(root, kind, key, text, "user", mo, embedModel, target);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"ApplyMemoryActions: {ex.Message}");
+ }
+ }
+ }
+}
diff --git a/AssistentConfig.cs b/AssistentConfig.cs
index b2a71f9..5d27819 100644
--- a/AssistentConfig.cs
+++ b/AssistentConfig.cs
@@ -189,7 +189,8 @@ public sealed class AssistentConfig
return last;
}
- List PersonaExtendsChain(string personaId)
+ /// Ancestor-first chain ending with the current persona (for overlay merge and vector retrieve).
+ public List PersonaExtendsChain(string personaId)
{
List chain = [];
HashSet seen = new(StringComparer.OrdinalIgnoreCase);
@@ -498,27 +499,35 @@ public sealed class AssistentConfig
JObject rules = MergeJsonLayers("rules.json", roots);
string extra = MergeTextLayers("extra.md", roots);
- // Legacy personas.json prompt → extra overlay
- string overlayJson = Path.Combine(_overlayRoot, "personas.json");
- JObject legacy = TryReadJson(overlayJson);
- if (legacy?["personas"] is JArray arr)
+ // Legacy personas.json: only when no overlay persona folder exists for this id
+ // (gpu-rent now seeds personas//extra.md instead of dumping personas.json).
+ string id = SafeId(personaId) ?? "neutral";
+ string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id);
+ bool hasOverlayFolder = Directory.Exists(overlayPersonaDir)
+ && (File.Exists(Path.Combine(overlayPersonaDir, "persona.json"))
+ || File.Exists(Path.Combine(overlayPersonaDir, "extra.md")));
+ if (!hasOverlayFolder)
{
- string id = SafeId(personaId) ?? "neutral";
- foreach (JToken t in arr)
+ string overlayJson = Path.Combine(_overlayRoot, "personas.json");
+ JObject legacy = TryReadJson(overlayJson);
+ if (legacy?["personas"] is JArray arr)
{
- if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
+ foreach (JToken t in arr)
{
- string title = po["title"]?.ToString();
- if (!string.IsNullOrWhiteSpace(title))
+ if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
{
- persona["title"] = title;
+ string title = po["title"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(title))
+ {
+ persona["title"] = title;
+ }
+ string prompt = po["prompt"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(prompt))
+ {
+ extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
+ }
+ break;
}
- string prompt = po["prompt"]?.ToString();
- if (!string.IsNullOrWhiteSpace(prompt))
- {
- extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
- }
- break;
}
}
}
@@ -641,35 +650,51 @@ public sealed class AssistentConfig
return sb.ToString().TrimEnd();
}
+ static IEnumerable PersonaIdsUnder(string root)
+ {
+ string dir = Path.Combine(root ?? "", "personas");
+ if (!Directory.Exists(dir))
+ {
+ yield break;
+ }
+ foreach (string folder in Directory.GetDirectories(dir))
+ {
+ string id = SafeId(Path.GetFileName(folder));
+ if (id is not null)
+ {
+ yield return id;
+ }
+ }
+ }
+
+ /// Seed docs: shared from _base/memory-seed, personal from personas/<id>/memory-seed. Later files overwrite earlier same kind+key in the list; persona is stamped on each doc.
public List LoadMemorySeedDocs()
{
List docs = [];
- void Scan(string root)
+ void Scan(string dir, string persona)
{
- string dir = Path.Combine(root, "memory-seed");
- if (!Directory.Exists(dir))
+ if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
{
return;
}
+ string stamp = AssistentMemory.NormalizePersona(persona);
foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
{
try
{
string raw = File.ReadAllText(file, Encoding.UTF8);
JToken parsed = JToken.Parse(raw);
- if (parsed is JArray arr)
+ IEnumerable items = parsed is JArray arr
+ ? arr.OfType()
+ : parsed is JObject single ? new[] { single } : [];
+ foreach (JObject jo in items)
{
- foreach (JToken t in arr)
+ JObject clone = (JObject)jo.DeepClone();
+ if (clone["persona"] is null || string.IsNullOrWhiteSpace(clone["persona"]?.ToString()))
{
- if (t is JObject jo)
- {
- docs.Add(jo);
- }
+ clone["persona"] = stamp;
}
- }
- else if (parsed is JObject single)
- {
- docs.Add(single);
+ docs.Add(clone);
}
}
catch (Exception ex)
@@ -678,9 +703,15 @@ public sealed class AssistentConfig
}
}
}
- Scan(Path.Combine(_bundledRoot, "_base"));
- Scan(Path.Combine(_overlayRoot, "_base"));
- Scan(Path.Combine(_overlayRoot, "memory-seed"));
+
+ Scan(Path.Combine(_bundledRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
+ Scan(Path.Combine(_overlayRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
+ Scan(Path.Combine(_overlayRoot, "memory-seed"), AssistentMemory.SharedPersona);
+ foreach (string id in PersonaIdsUnder(_bundledRoot).Concat(PersonaIdsUnder(_overlayRoot)).Distinct(StringComparer.OrdinalIgnoreCase))
+ {
+ Scan(Path.Combine(_bundledRoot, "personas", id, "memory-seed"), id);
+ Scan(Path.Combine(_overlayRoot, "personas", id, "memory-seed"), id);
+ }
return docs;
}
diff --git a/AssistentInventory.cs b/AssistentInventory.cs
new file mode 100644
index 0000000..d334c5e
--- /dev/null
+++ b/AssistentInventory.cs
@@ -0,0 +1,829 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using FreneticUtilities.FreneticExtensions;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Core;
+using SwarmUI.Text2Image;
+using SwarmUI.Utils;
+using SwarmUI.WebAPI;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Server-side model inventory, assistant cards and Civitai lookups.
+public partial class SwarmAssistentExtension
+{
+ const int MaxLorasInInventoryFallback = 150;
+ const int MaxWildcardsInInventoryFallback = 80;
+ const int MaxCheckpointsInInventoryFallback = 60;
+ const int InventoryBlurbMaxFallback = 140;
+
+ 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);
+ _ = IngestCardToMemory(card, name);
+ 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 rawVid = card["version_id"]?.ToString() ?? "draft";
+ string vid = Regex.IsMatch(rawVid, @"^\d+$") ? rawVid : "draft";
+ string draft = Path.Combine(WantedCardsDir(), $"{vid}.assistent.json");
+ File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
+ if (enqueue_wanted || !string.IsNullOrWhiteSpace(card["civitai_url"]?.ToString()))
+ {
+ await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value() ?? 0, card["title"]?.ToString() ?? name, card);
+ }
+ _ = IngestCardToMemory(card, name);
+ return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
+ }
+
+ async Task IngestCardToMemory(JObject card, string name)
+ {
+ if (Memory is null || card is null)
+ {
+ return;
+ }
+ try
+ {
+ string kind = (card["kind"]?.ToString() ?? "lora").Trim().ToLowerInvariant();
+ string key = (card["name"]?.ToString() ?? name ?? "").Trim();
+ List bits = [];
+ foreach (string field in new[] { "when", "avoid", "prompt_hint", "notes" })
+ {
+ string v = card[field]?.ToString();
+ if (!string.IsNullOrWhiteSpace(v))
+ {
+ bits.Add($"{field}: {v.Trim()}");
+ }
+ }
+ if (card["triggers"] is JArray tr)
+ {
+ string joined = string.Join(", ", tr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
+ if (!string.IsNullOrWhiteSpace(joined))
+ {
+ bits.Add("triggers: " + joined);
+ }
+ }
+ if (bits.Count == 0 || string.IsNullOrWhiteSpace(key))
+ {
+ return;
+ }
+ string text = $"{kind} {key}. " + string.Join(" ", bits);
+ string baseUrl = NormalizeBaseUrl(Config.LoadSettings()["base_url"]?.ToString());
+ string embedModel = Config.LoadSettings()["embed_model"]?.ToString()
+ ?? Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString();
+ await Memory.UpsertTextAsync(baseUrl, "card", key, text, "user", card, embedModel, AssistentMemory.SharedPersona);
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"IngestCardToMemory: {ex.Message}");
+ }
+ }
+
+ public async Task AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0, bool fetch = false)
+ {
+ string set = SetNameForKind(kind);
+ string weight = ModelWeightPath(set, name);
+ JObject civitai = null;
+ JArray exampleUrls = [];
+ JArray previewUrls = [];
+ bool hasSidecar = false;
+ string fetchError = null;
+ bool fetched = false;
+
+ if (!string.IsNullOrWhiteSpace(weight))
+ {
+ string stem = Path.GetFileNameWithoutExtension(weight);
+ string dir = Path.GetDirectoryName(weight);
+ string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
+ if (File.Exists(side))
+ {
+ hasSidecar = true;
+ try
+ {
+ civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8));
+ }
+ catch
+ {
+ // ignore
+ }
+ }
+ foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
+ {
+ string prev = Path.Combine(dir ?? "", stem + suffix);
+ if (File.Exists(prev))
+ {
+ // Swarm View path — relative URL works in the same origin browser session.
+ previewUrls.Add($"View/Models/{(kind == "lora" ? "Lora" : "Stable-Diffusion")}/{Path.GetFileName(prev)}");
+ break;
+ }
+ }
+ }
+
+ if (civitai is not null)
+ {
+ if (version_id <= 0)
+ {
+ version_id = civitai["id"]?.Value() ?? 0;
+ }
+ CollectExampleUrls(civitai, exampleUrls);
+ }
+
+ string hash = null;
+ string trigger = null;
+ try
+ {
+ if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h)
+ && (h.Models.TryGetValue(name, out T2IModel m)
+ || h.Models.TryGetValue(name.Replace('\\', '/'), out m)))
+ {
+ trigger = m.Metadata?.TriggerPhrase;
+ hash = m.Metadata?.Hash;
+ }
+ }
+ catch
+ {
+ // ignore
+ }
+
+ if (fetch && civitai is null)
+ {
+ string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ fetchError = "Civitai: нет ключа в User Settings";
+ }
+ else
+ {
+ try
+ {
+ JObject remote = null;
+ if (version_id > 0)
+ {
+ remote = await FetchCivitaiModelVersion(apiKey, version_id);
+ }
+ if (remote is null && !string.IsNullOrWhiteSpace(hash))
+ {
+ string sha = hash.Trim().ToLowerInvariant();
+ if (sha.StartsWith("sha256:"))
+ {
+ sha = sha["sha256:".Length..];
+ }
+ if (sha.Length == 64)
+ {
+ remote = await FetchCivitaiByHash(apiKey, sha);
+ }
+ else
+ {
+ fetchError ??= "Civitai: хеш модели не SHA256";
+ }
+ }
+ if (remote is not null)
+ {
+ civitai = remote;
+ fetched = true;
+ version_id = remote["id"]?.Value() ?? version_id;
+ CollectExampleUrls(remote, exampleUrls);
+ if (!string.IsNullOrWhiteSpace(weight))
+ {
+ try
+ {
+ string stem = Path.GetFileNameWithoutExtension(weight);
+ string dir = Path.GetDirectoryName(weight);
+ string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
+ File.WriteAllText(side, remote.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
+ hasSidecar = true;
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentGetCardMeta write sidecar: {ex.Message}");
+ }
+ }
+ }
+ else if (fetchError is null)
+ {
+ fetchError = string.IsNullOrWhiteSpace(hash)
+ ? "Civitai: нет hash и version_id"
+ : "Хеш не найден на Civitai";
+ }
+ }
+ catch (Exception ex)
+ {
+ fetchError = $"Civitai: {ex.Message}";
+ }
+ }
+ }
+
+ JObject card = ReadCardObject(kind, name);
+ return new JObject
+ {
+ ["success"] = true,
+ ["kind"] = kind,
+ ["name"] = name,
+ ["version_id"] = version_id,
+ ["trigger_phrase"] = trigger,
+ ["has_card"] = card is not null,
+ ["has_sidecar"] = hasSidecar,
+ ["fetched"] = fetched,
+ ["fetch_error"] = fetchError,
+ ["card"] = card,
+ ["civitai"] = civitai,
+ ["example_urls"] = exampleUrls,
+ ["preview_urls"] = previewUrls,
+ ["weight_path"] = weight,
+ ["hash"] = hash,
+ };
+ }
+
+ static void CollectExampleUrls(JObject civitai, JArray exampleUrls)
+ {
+ if (civitai?["images"] is not JArray imgs)
+ {
+ return;
+ }
+ foreach (JToken img in imgs.Take(6))
+ {
+ string u = img?["url"]?.ToString();
+ if (!string.IsNullOrWhiteSpace(u))
+ {
+ exampleUrls.Add(u);
+ }
+ }
+ }
+
+ async Task FetchCivitaiByHash(string apiKey, string sha)
+ {
+ string[] hosts = ["civitai.red", "civitai.com"];
+ Exception last = null;
+ foreach (string host in hosts)
+ {
+ try
+ {
+ string url = $"https://{host}/api/v1/model-versions/by-hash/{sha}";
+ using HttpRequestMessage req = new(HttpMethod.Get, url);
+ if (!string.IsNullOrWhiteSpace(apiKey))
+ {
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
+ }
+ using HttpResponseMessage resp = await HttpClient.SendAsync(req);
+ string body = await resp.Content.ReadAsStringAsync();
+ if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ continue;
+ }
+ if (!resp.IsSuccessStatusCode)
+ {
+ last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
+ if ((int)resp.StatusCode is 401 or 403)
+ {
+ throw last;
+ }
+ continue;
+ }
+ return JObject.Parse(body);
+ }
+ catch (Exception ex) when (ex is not HttpRequestException && ex.Message.Contains("401"))
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ last = ex;
+ }
+ }
+ if (last is not null)
+ {
+ throw last;
+ }
+ return null;
+ }
+
+ async Task FetchCivitaiModelVersion(string apiKey, int versionId)
+ {
+ string[] hosts = ["civitai.red", "civitai.com"];
+ Exception last = null;
+ foreach (string host in hosts)
+ {
+ try
+ {
+ string url = $"https://{host}/api/v1/model-versions/{versionId}";
+ using HttpRequestMessage req = new(HttpMethod.Get, url);
+ if (!string.IsNullOrWhiteSpace(apiKey))
+ {
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
+ }
+ using HttpResponseMessage resp = await HttpClient.SendAsync(req);
+ string body = await resp.Content.ReadAsStringAsync();
+ if (!resp.IsSuccessStatusCode)
+ {
+ last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
+ if ((int)resp.StatusCode is 401 or 403)
+ {
+ throw last;
+ }
+ continue;
+ }
+ return JObject.Parse(body);
+ }
+ catch (Exception ex)
+ {
+ last = ex;
+ if (ex.Message.Contains("401") || ex.Message.Contains("403"))
+ {
+ throw;
+ }
+ }
+ }
+ if (last is not null)
+ {
+ throw last;
+ }
+ return null;
+ }
+
+ /// Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
+ /// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).
+ public async Task AssistentListInventory(Session session, bool rescan = false)
+ {
+ 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
+ .OrderByDescending(m => LooksLikeKreaArch(m))
+ .ThenBy(m => m.Name)
+ .Take(CfgInt("max_loras_inventory", MaxLorasInInventoryFallback)))
+ {
+ loras.Add(BuildInventoryModelEntry(model, "lora"));
+ }
+ }
+
+ if (Program.T2IModelSets.TryGetValue("Stable-Diffusion", out T2IModelHandler ckptHandler))
+ {
+ foreach (T2IModel model in ckptHandler.Models.Values
+ .OrderByDescending(m => LooksLikeKreaArch(m))
+ .ThenBy(m => m.Name)
+ .Take(CfgInt("max_checkpoints_inventory", MaxCheckpointsInInventoryFallback)))
+ {
+ checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint"));
+ }
+ }
+
+ try
+ {
+ foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(CfgInt("max_wildcards_inventory", MaxWildcardsInInventoryFallback)))
+ {
+ wildcards.Add(new JObject { ["name"] = name });
+ }
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentListInventory wildcards: {ex.Message}");
+ }
+
+ bool hasCivitaiKey = !string.IsNullOrWhiteSpace(session.User.GetGenericData("civitai_api", "key"));
+
+ return new JObject
+ {
+ ["success"] = true,
+ ["loras"] = loras,
+ ["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(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
+ }
+ }
+ catch
+ {
+ // ignore bad card json
+ }
+ }
+ if (string.IsNullOrWhiteSpace(blurb))
+ {
+ string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
+ if (!string.IsNullOrWhiteSpace(raw))
+ {
+ blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
+ }
+ }
+
+ 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 (!string.IsNullOrWhiteSpace(weight))
+ {
+ string stem = Path.GetFileNameWithoutExtension(weight);
+ string dir = Path.GetDirectoryName(weight);
+ string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
+ entry["has_sidecar"] = File.Exists(side);
+ foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
+ {
+ string prev = Path.Combine(dir ?? "", stem + suffix);
+ if (File.Exists(prev))
+ {
+ string folder = kind == "lora" ? "Lora" : "Stable-Diffusion";
+ entry["preview_url"] = $"View/Models/{folder}/{Path.GetFileName(prev)}";
+ break;
+ }
+ }
+ }
+ else
+ {
+ entry["has_sidecar"] = false;
+ }
+ if (triggers is not null && triggers.Count > 0)
+ {
+ entry["triggers"] = triggers;
+ }
+ 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;
+ }
+
+ /// 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)
+ {
+ string q = (query ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ return new JObject { ["error"] = "query is required" };
+ }
+ limit = Math.Clamp(limit, 1, 20);
+ string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
+ HashSet installedNames = CollectInstalledLoraNames();
+ HashSet installedHashes = CollectInstalledLoraHashes();
+
+ string[] hosts = ["civitai.red", "civitai.com"];
+ Exception lastEx = null;
+ foreach (string host in hosts)
+ {
+ try
+ {
+ string url = $"https://{host}/api/v1/models?limit={limit}&types=LORA&query={Uri.EscapeDataString(q)}";
+ using HttpRequestMessage req = new(HttpMethod.Get, url);
+ if (!string.IsNullOrWhiteSpace(apiKey))
+ {
+ req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
+ }
+ using HttpResponseMessage resp = await HttpClient.SendAsync(req);
+ string body = await resp.Content.ReadAsStringAsync();
+ if (!resp.IsSuccessStatusCode)
+ {
+ lastEx = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 200)}");
+ continue;
+ }
+ JObject parsed = JObject.Parse(body);
+ JArray items = parsed["items"] as JArray ?? [];
+ JArray results = [];
+ foreach (JToken item in items)
+ {
+ if (item is not JObject mo)
+ {
+ continue;
+ }
+ JObject card = BuildCivitaiCard(mo, installedNames, installedHashes);
+ if (card is not null)
+ {
+ results.Add(card);
+ }
+ }
+ // Prefer Krea-compatible first
+ JArray sorted = new(results.OrderByDescending(t => LooksLikeKrea(t["base_model"]?.ToString())).ThenBy(t => t["name"]?.ToString()));
+ return new JObject
+ {
+ ["success"] = true,
+ ["query"] = q,
+ ["host"] = host,
+ ["results"] = sorted,
+ ["has_civitai_key"] = !string.IsNullOrWhiteSpace(apiKey),
+ };
+ }
+ catch (Exception ex)
+ {
+ lastEx = ex;
+ }
+ }
+ return new JObject { ["error"] = $"Civitai search failed: {lastEx?.Message ?? "unknown"}" };
+ }
+
+ static bool LooksLikeKrea(string text) => !string.IsNullOrEmpty(text) && Regex.IsMatch(text, @"krea", RegexOptions.IgnoreCase);
+
+ static HashSet CollectInstalledLoraNames()
+ {
+ HashSet names = new(StringComparer.OrdinalIgnoreCase);
+ if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
+ {
+ return names;
+ }
+ foreach (T2IModel m in handler.Models.Values)
+ {
+ names.Add(m.Name);
+ string leaf = m.Name.Replace('\\', '/').AfterLast('/');
+ if (!string.IsNullOrEmpty(leaf))
+ {
+ names.Add(leaf);
+ names.Add(Path.GetFileNameWithoutExtension(leaf));
+ }
+ }
+ return names;
+ }
+
+ static HashSet CollectInstalledLoraHashes()
+ {
+ HashSet hashes = new(StringComparer.OrdinalIgnoreCase);
+ if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
+ {
+ return hashes;
+ }
+ foreach (T2IModel m in handler.Models.Values)
+ {
+ string h = m.Metadata?.Hash;
+ if (!string.IsNullOrWhiteSpace(h))
+ {
+ hashes.Add(h.Trim().ToLowerInvariant());
+ }
+ }
+ return hashes;
+ }
+
+ static JObject BuildCivitaiCard(JObject model, HashSet installedNames, HashSet installedHashes)
+ {
+ string name = model["name"]?.ToString() ?? "";
+ JArray versions = model["modelVersions"] as JArray;
+ JObject ver = versions?.FirstOrDefault() as JObject;
+ if (ver is null)
+ {
+ return null;
+ }
+ string baseModel = ver["baseModel"]?.ToString() ?? "";
+ JArray trained = ver["trainedWords"] as JArray ?? [];
+ List triggers = trained.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).Take(8).ToList();
+ JObject file = null;
+ foreach (JToken f in ver["files"] as JArray ?? [])
+ {
+ if (f is JObject fo && (fo["primary"]?.Value() == true || (fo["name"]?.ToString() ?? "").EndsWith(".safetensors", StringComparison.OrdinalIgnoreCase)))
+ {
+ file = fo;
+ break;
+ }
+ }
+ file ??= (ver["files"] as JArray)?.FirstOrDefault() as JObject;
+ string downloadUrl = file?["downloadUrl"]?.ToString() ?? ver["downloadUrl"]?.ToString() ?? "";
+ string fileName = file?["name"]?.ToString() ?? "";
+ string sha = file?["hashes"]?["SHA256"]?.ToString() ?? file?["hashes"]?["AutoV2"]?.ToString() ?? "";
+ string saveName = string.IsNullOrWhiteSpace(fileName)
+ ? Regex.Replace(name, @"[^\w\-.]+", "_").Trim('_')
+ : Path.GetFileNameWithoutExtension(fileName);
+
+ bool already = false;
+ if (!string.IsNullOrWhiteSpace(sha) && installedHashes.Contains(sha.Trim().ToLowerInvariant()))
+ {
+ already = true;
+ }
+ else if (installedNames.Contains(saveName) || installedNames.Contains(name) || installedNames.Contains(fileName))
+ {
+ already = true;
+ }
+
+ return new JObject
+ {
+ ["id"] = model["id"],
+ ["version_id"] = ver["id"],
+ ["name"] = name,
+ ["base_model"] = baseModel,
+ ["krea_likely"] = LooksLikeKrea(baseModel),
+ ["triggers"] = new JArray(triggers),
+ ["download_url"] = downloadUrl,
+ ["file_name"] = saveName,
+ ["sha256"] = sha,
+ ["already_installed"] = already,
+ ["n_sfw"] = model["nsfw"]?.Value() ?? false,
+ };
+ }
+}
diff --git a/AssistentMemory.cs b/AssistentMemory.cs
index 7c931f2..2f1c0e5 100644
--- a/AssistentMemory.cs
+++ b/AssistentMemory.cs
@@ -11,9 +11,13 @@ using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
-/// Local SQLite vector memory with Ollama /api/embed.
+/// Local SQLite vector memory with Ollama /api/embed.
+/// Two layers: shared (persona='') is visible to every personality; personal (persona=id)
+/// is not written back to shared. On retrieve, personal overwrites shared on the same kind+key.
public sealed class AssistentMemory : IDisposable
{
+ public const string SharedPersona = "";
+
readonly string _dbPath;
readonly HttpClient _http;
readonly object _lock = new();
@@ -35,6 +39,22 @@ public sealed class AssistentMemory : IDisposable
public int Dims => _dims;
public int SeedVersion => _seedVersion;
+ public static string NormalizePersona(string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return SharedPersona;
+ }
+ string s = raw.Trim();
+ if (s is "_" or "*" or "shared" or "common" or "global" or "_shared")
+ {
+ return SharedPersona;
+ }
+ return AssistentConfig.SafeId(s) ?? SharedPersona;
+ }
+
+ public static bool IsShared(string persona) => string.IsNullOrEmpty(NormalizePersona(persona));
+
void EnsureOpen()
{
if (_conn is not null)
@@ -55,22 +75,85 @@ public sealed class AssistentMemory : IDisposable
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
key TEXT NOT NULL,
+ persona TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'user',
meta_json TEXT,
embedding BLOB,
updated INTEGER NOT NULL,
- UNIQUE(kind, key, source)
+ UNIQUE(kind, key, source, persona)
);
- CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
""";
cmd.ExecuteNonQuery();
}
+ MigratePersonaColumn();
+ using (SqliteCommand idx = _conn.CreateCommand())
+ {
+ idx.CommandText =
+ """
+ CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
+ CREATE INDEX IF NOT EXISTS idx_memories_persona ON memories(persona);
+ """;
+ idx.ExecuteNonQuery();
+ }
_embedModel = GetMeta("embed_model") ?? _embedModel;
_ = int.TryParse(GetMeta("dims"), out _dims);
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
}
+ bool HasColumn(string table, string column)
+ {
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText = $"PRAGMA table_info({table})";
+ using SqliteDataReader reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ void MigratePersonaColumn()
+ {
+ if (HasColumn("memories", "persona"))
+ {
+ return;
+ }
+ using SqliteTransaction tx = _conn.BeginTransaction();
+ using (SqliteCommand cmd = _conn.CreateCommand())
+ {
+ cmd.Transaction = tx;
+ cmd.CommandText =
+ """
+ CREATE TABLE memories_v2 (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ kind TEXT NOT NULL,
+ key TEXT NOT NULL,
+ persona TEXT NOT NULL DEFAULT '',
+ text TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'user',
+ meta_json TEXT,
+ embedding BLOB,
+ updated INTEGER NOT NULL,
+ UNIQUE(kind, key, source, persona)
+ );
+ INSERT INTO memories_v2 (kind, key, persona, text, source, meta_json, embedding, updated)
+ SELECT kind, key, '', text, source, meta_json, embedding, updated FROM memories;
+ DROP TABLE memories;
+ ALTER TABLE memories_v2 RENAME TO memories;
+ CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
+ CREATE INDEX IF NOT EXISTS idx_memories_persona ON memories(persona);
+ """;
+ cmd.ExecuteNonQuery();
+ }
+ tx.Commit();
+ SetMeta("schema_version", "2");
+ Logs.Debug("AssistentMemory: migrated sqlite to shared+personal persona column (existing rows → shared)");
+ }
+
string GetMeta(string key)
{
using SqliteCommand cmd = _conn.CreateCommand();
@@ -126,6 +209,11 @@ public sealed class AssistentMemory : IDisposable
return (float)(dot / (Math.Sqrt(na) * Math.Sqrt(nb)));
}
+ static int SourceRank(string source)
+ {
+ return string.Equals(source, "user", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
+ }
+
public async Task EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m")
{
string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/');
@@ -154,6 +242,16 @@ public sealed class AssistentMemory : IDisposable
return floats;
}
+ bool BundledExists(string kind, string key, string persona)
+ {
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText = "SELECT 1 FROM memories WHERE source = 'bundled' AND kind = $kind AND key = $key AND persona = $persona LIMIT 1";
+ cmd.Parameters.AddWithValue("$kind", kind);
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.Parameters.AddWithValue("$persona", persona ?? SharedPersona);
+ return cmd.ExecuteScalar() is not null;
+ }
+
public async Task EnsureSeedAsync(string baseUrl, AssistentConfig config, string modelOverride = null)
{
lock (_lock)
@@ -167,28 +265,32 @@ public sealed class AssistentMemory : IDisposable
: modelOverride.Trim();
bool needReseed = _seedVersion != wantVersion || !string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase);
- if (!needReseed)
- {
- int bundledCount;
- lock (_lock)
- {
- using SqliteCommand cmd = _conn.CreateCommand();
- cmd.CommandText = "SELECT COUNT(*) FROM memories WHERE source = 'bundled'";
- bundledCount = Convert.ToInt32(cmd.ExecuteScalar());
- }
- if (bundledCount > 0)
- {
- return;
- }
- }
-
List docs = config.LoadMemorySeedDocs();
if (docs.Count == 0)
{
return;
}
- // Probe embed
+ if (!needReseed)
+ {
+ bool missing;
+ lock (_lock)
+ {
+ EnsureOpen();
+ missing = docs.Any(d =>
+ {
+ string kind = (d["kind"]?.ToString() ?? "note").Trim().ToLowerInvariant();
+ string key = (d["key"]?.ToString() ?? "").Trim();
+ string persona = NormalizePersona(d["persona"]?.ToString());
+ return !string.IsNullOrWhiteSpace(key) && !BundledExists(kind, key, persona);
+ });
+ }
+ if (!missing)
+ {
+ return;
+ }
+ }
+
float[] probe;
try
{
@@ -228,14 +330,26 @@ public sealed class AssistentMemory : IDisposable
string kind = (doc["kind"]?.ToString() ?? "note").Trim();
string key = (doc["key"]?.ToString() ?? "").Trim();
string text = (doc["text"]?.ToString() ?? "").Trim();
+ string persona = NormalizePersona(doc["persona"]?.ToString());
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
{
continue;
}
+ if (!needReseed)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ if (BundledExists(kind.ToLowerInvariant(), key, persona))
+ {
+ continue;
+ }
+ }
+ }
try
{
float[] vec = await EmbedAsync(baseUrl, wantModel, text);
- Upsert(kind, key, text, "bundled", doc["tags"], vec);
+ Upsert(kind, key, text, "bundled", doc["tags"], vec, persona);
}
catch (Exception ex)
{
@@ -244,7 +358,7 @@ public sealed class AssistentMemory : IDisposable
}
}
- public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding)
+ public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding, string persona = null)
{
lock (_lock)
{
@@ -253,6 +367,7 @@ public sealed class AssistentMemory : IDisposable
key = (key ?? "").Trim();
text = (text ?? "").Trim();
source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim();
+ persona = NormalizePersona(persona);
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
{
return;
@@ -268,9 +383,9 @@ public sealed class AssistentMemory : IDisposable
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
- INSERT INTO memories(kind, key, text, source, meta_json, embedding, updated)
- VALUES($kind, $key, $text, $source, $meta, $emb, $upd)
- ON CONFLICT(kind, key, source) DO UPDATE SET
+ INSERT INTO memories(kind, key, persona, text, source, meta_json, embedding, updated)
+ VALUES($kind, $key, $persona, $text, $source, $meta, $emb, $upd)
+ ON CONFLICT(kind, key, source, persona) DO UPDATE SET
text = excluded.text,
meta_json = excluded.meta_json,
embedding = excluded.embedding,
@@ -278,6 +393,7 @@ public sealed class AssistentMemory : IDisposable
""";
cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$key", key);
+ cmd.Parameters.AddWithValue("$persona", persona);
cmd.Parameters.AddWithValue("$text", text);
cmd.Parameters.AddWithValue("$source", source);
cmd.Parameters.AddWithValue("$meta", meta?.ToString(Newtonsoft.Json.Formatting.None) ?? "");
@@ -287,28 +403,33 @@ public sealed class AssistentMemory : IDisposable
}
}
- public void Forget(string kind, string key, string source = null)
+ /// Delete a row in one layer. Default (persona=current) never touches shared.
+ /// Pass SharedPersona to forget a shared fact. Bundled rows are kept unless source is set.
+ public void Forget(string kind, string key, string source = null, string persona = null)
{
lock (_lock)
{
EnsureOpen();
+ persona = NormalizePersona(persona);
using SqliteCommand cmd = _conn.CreateCommand();
if (string.IsNullOrWhiteSpace(source))
{
- cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source != 'bundled'";
+ cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND persona = $persona AND source != 'bundled'";
}
else
{
- cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source = $source";
+ cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND persona = $persona AND source = $source";
cmd.Parameters.AddWithValue("$source", source);
}
cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant());
cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
+ cmd.Parameters.AddWithValue("$persona", persona);
cmd.ExecuteNonQuery();
}
}
- public async Task RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null)
+ /// Retrieve shared + the given persona chain. Personal overwrites shared (and parent personas) on kind+key.
+ public async Task RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable personaChain = null)
{
if (string.IsNullOrWhiteSpace(query))
{
@@ -330,53 +451,143 @@ public sealed class AssistentMemory : IDisposable
return [];
}
- List<(float score, JObject row)> scored = [];
+ Dictionary rank = new(StringComparer.OrdinalIgnoreCase)
+ {
+ [SharedPersona] = 0,
+ };
+ int i = 1;
+ foreach (string id in personaChain ?? [])
+ {
+ string p = NormalizePersona(id);
+ if (p == SharedPersona)
+ {
+ continue;
+ }
+ rank[p] = i++;
+ }
+
+ List<(float score, int personaRank, int sourceRank, JObject row)> scored = [];
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
- cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding FROM memories WHERE embedding IS NOT NULL";
+ cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding, persona FROM memories WHERE embedding IS NOT NULL";
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
+ string persona = reader.IsDBNull(6) ? SharedPersona : reader.GetString(6) ?? SharedPersona;
+ if (!rank.TryGetValue(persona, out int personaRank))
+ {
+ continue;
+ }
float[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
float score = Cosine(q, emb);
if (float.IsNegativeInfinity(score))
{
continue;
}
- scored.Add((score, new JObject
+ string kind = reader.GetString(0);
+ string key = reader.GetString(1);
+ string source = reader.GetString(3);
+ bool shared = persona == SharedPersona;
+ scored.Add((score, personaRank, SourceRank(source), new JObject
+ {
+ ["kind"] = kind,
+ ["key"] = key,
+ ["text"] = reader.GetString(2),
+ ["source"] = source,
+ ["scope"] = shared ? "shared" : "personal",
+ ["persona"] = shared ? "shared" : persona,
+ ["score"] = Math.Round(score, 4),
+ }));
+ }
+ }
+
+ // Personal (and later parents) overwrite shared on the same kind+key; user beats bundled.
+ Dictionary best = new(StringComparer.OrdinalIgnoreCase);
+ foreach (var item in scored)
+ {
+ string id = $"{item.row["kind"]}\n{item.row["key"]}";
+ if (best.TryGetValue(id, out var cur))
+ {
+ if (item.personaRank < cur.personaRank
+ || (item.personaRank == cur.personaRank && item.sourceRank < cur.sourceRank)
+ || (item.personaRank == cur.personaRank && item.sourceRank == cur.sourceRank && item.score <= cur.score))
+ {
+ continue;
+ }
+ }
+ best[id] = item;
+ }
+
+ return new JArray(best.Values.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
+ }
+
+ public JArray ListAll(int limit = 200)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ List rows = [];
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories ORDER BY updated DESC LIMIT $lim";
+ cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 2000));
+ using SqliteDataReader reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ string persona = reader.IsDBNull(4) ? SharedPersona : reader.GetString(4) ?? SharedPersona;
+ bool shared = string.IsNullOrEmpty(persona);
+ rows.Add(new JObject
{
["kind"] = reader.GetString(0),
["key"] = reader.GetString(1),
["text"] = reader.GetString(2),
["source"] = reader.GetString(3),
- ["score"] = Math.Round(score, 4),
- }));
+ ["scope"] = shared ? "shared" : "personal",
+ ["persona"] = shared ? "shared" : persona,
+ ["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
+ });
}
+ return new JArray(rows);
}
- return new JArray(scored.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
}
- public async Task UpsertTextAsync(string baseUrl, string kind, string key, string text, string source = "user", JToken meta = null, string modelOverride = null)
+ public int CountAll()
{
- string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
- float[] vec = await EmbedAsync(baseUrl, model, text);
- Upsert(kind, key, text, source, meta, vec);
- }
-
- public async Task ReembedAllAsync(string baseUrl, string newModel)
- {
- List<(string kind, string key, string text, string source, string meta)> rows = [];
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
- cmd.CommandText = "SELECT kind, key, text, source, meta_json FROM memories";
+ cmd.CommandText = "SELECT COUNT(*) FROM memories";
+ return Convert.ToInt32(cmd.ExecuteScalar());
+ }
+ }
+
+ public async Task UpsertTextAsync(string baseUrl, string kind, string key, string text, string source = "user", JToken meta = null, string modelOverride = null, string persona = null)
+ {
+ string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
+ float[] vec = await EmbedAsync(baseUrl, model, text);
+ Upsert(kind, key, text, source, meta, vec, persona);
+ }
+
+ public async Task ReembedAllAsync(string baseUrl, string newModel)
+ {
+ List<(string kind, string key, string text, string source, string meta, string persona)> rows = [];
+ lock (_lock)
+ {
+ EnsureOpen();
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText = "SELECT kind, key, text, source, meta_json, persona FROM memories";
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
- rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.IsDBNull(4) ? "" : reader.GetString(4)));
+ rows.Add((
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.GetString(2),
+ reader.GetString(3),
+ reader.IsDBNull(4) ? "" : reader.GetString(4),
+ reader.FieldCount > 5 && !reader.IsDBNull(5) ? reader.GetString(5) : SharedPersona));
}
}
if (rows.Count == 0)
@@ -408,7 +619,7 @@ public sealed class AssistentMemory : IDisposable
{
try { meta = JToken.Parse(row.meta); } catch { /* ignore */ }
}
- Upsert(row.kind, row.key, row.text, row.source, meta, vec);
+ Upsert(row.kind, row.key, row.text, row.source, meta, vec, row.persona);
}
catch (Exception ex)
{
diff --git a/AssistentMemoryApi.cs b/AssistentMemoryApi.cs
new file mode 100644
index 0000000..536e476
--- /dev/null
+++ b/AssistentMemoryApi.cs
@@ -0,0 +1,215 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Utils;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Read/write routes for the vector memory list in ⚙ and the gpu-rent wanted queue badge.
+public partial class SwarmAssistentExtension
+{
+ /// Embed model the UI should use: settings overlay wins, then persona assistant.json.
+ string MemoryEmbedModel(string requested = null)
+ {
+ if (!string.IsNullOrWhiteSpace(requested))
+ {
+ return requested.Trim();
+ }
+ return Config?.LoadSettings()["embed_model"]?.ToString()
+ ?? Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString()
+ ?? "nomic-embed-text";
+ }
+
+ string MemoryBaseUrl(string requested = null)
+ {
+ return NormalizeBaseUrl(string.IsNullOrWhiteSpace(requested)
+ ? Config?.LoadSettings()["base_url"]?.ToString()
+ : requested);
+ }
+
+ string ResolveApiPersona(string persona, string scope)
+ {
+ string s = (scope ?? "").Trim().ToLowerInvariant();
+ if (s is "shared" or "common" or "global")
+ {
+ return AssistentMemory.SharedPersona;
+ }
+ if (s is "personal")
+ {
+ return AssistentConfig.SafeId(persona) ?? Config?.DefaultPersonaId() ?? "neutral";
+ }
+ if (string.IsNullOrWhiteSpace(persona) || AssistentMemory.IsShared(persona))
+ {
+ return AssistentMemory.SharedPersona;
+ }
+ return AssistentMemory.NormalizePersona(persona);
+ }
+
+ public async Task AssistentListMemory(Session session, int limit = 200, string kind = null, string persona = null, string scope = null)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ try
+ {
+ JArray all = Memory.ListAll(limit);
+ string filter = (kind ?? "").Trim().ToLowerInvariant();
+ string wantScope = (scope ?? "").Trim().ToLowerInvariant();
+ string wantPersona = (persona ?? "").Trim();
+ IEnumerable q = all;
+ if (!string.IsNullOrWhiteSpace(filter) && filter != "all")
+ {
+ q = q.Where(t => string.Equals(t?["kind"]?.ToString(), filter, StringComparison.OrdinalIgnoreCase));
+ }
+ bool personaGiven = !string.IsNullOrWhiteSpace(wantPersona);
+ if (wantScope is "shared" or "common" or "global"
+ || (personaGiven && AssistentMemory.IsShared(wantPersona)))
+ {
+ q = q.Where(t => string.Equals(t?["scope"]?.ToString(), "shared", StringComparison.OrdinalIgnoreCase));
+ }
+ else if (wantScope is "personal" || personaGiven)
+ {
+ string pid = AssistentMemory.NormalizePersona(personaGiven ? wantPersona : Config?.DefaultPersonaId());
+ q = q.Where(t => string.Equals(t?["persona"]?.ToString(), pid, StringComparison.OrdinalIgnoreCase));
+ }
+ JArray rows = new(q);
+ JArray kinds = new(all
+ .Select(t => t?["kind"]?.ToString())
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
+ return new JObject
+ {
+ ["success"] = true,
+ ["memories"] = rows,
+ ["kinds"] = kinds,
+ ["total"] = Memory.CountAll(),
+ ["embed_model"] = Memory.EmbedModel,
+ ["dims"] = Memory.Dims,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"memory list: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentUpsertMemory(Session session, string kind, string key, string text, string source = "user", string baseUrl = null, string embed_model = null, string persona = null, string scope = null)
+ {
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ kind = (kind ?? "note").Trim().ToLowerInvariant();
+ key = (key ?? "").Trim();
+ text = (text ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
+ {
+ return new JObject { ["error"] = "key and text required" };
+ }
+ string src = (source ?? "user").Trim().ToLowerInvariant();
+ if (src == "bundled")
+ {
+ return new JObject { ["error"] = "bundled memories are read-only — use memory-seed/" };
+ }
+ string target = ResolveApiPersona(persona, scope);
+ try
+ {
+ await Memory.UpsertTextAsync(MemoryBaseUrl(baseUrl), kind, key, text, src, null, MemoryEmbedModel(embed_model), target);
+ return new JObject
+ {
+ ["success"] = true,
+ ["kind"] = kind,
+ ["key"] = key,
+ ["source"] = src,
+ ["scope"] = AssistentMemory.IsShared(target) ? "shared" : "personal",
+ ["persona"] = AssistentMemory.IsShared(target) ? "shared" : target,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"memory upsert: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentForgetMemory(Session session, string kind, string key, string source = null, string persona = null, string scope = null)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(key))
+ {
+ return new JObject { ["error"] = "kind and key required" };
+ }
+ if (string.Equals((source ?? "").Trim(), "bundled", StringComparison.OrdinalIgnoreCase))
+ {
+ return new JObject { ["error"] = "bundled memories come back on reseed — edit memory-seed/ instead" };
+ }
+ string target = ResolveApiPersona(persona, scope);
+ try
+ {
+ Memory.Forget(kind, key, string.IsNullOrWhiteSpace(source) ? null : source.Trim(), target);
+ return new JObject
+ {
+ ["success"] = true,
+ ["kind"] = kind.Trim().ToLowerInvariant(),
+ ["key"] = key.Trim(),
+ ["scope"] = AssistentMemory.IsShared(target) ? "shared" : "personal",
+ ["persona"] = AssistentMemory.IsShared(target) ? "shared" : target,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"memory forget: {ex.Message}" };
+ }
+ }
+
+ /// The gpu-rent wanted queue (models pending the next up) — count + entries.
+ public async Task AssistentListWanted(Session session)
+ {
+ await Task.CompletedTask;
+ string path = WantedModelsPath();
+ JArray items = [];
+ if (!File.Exists(path))
+ {
+ return new JObject { ["success"] = true, ["count"] = 0, ["items"] = items, ["path"] = path };
+ }
+ try
+ {
+ Dictionary> sections = LoadWantedYaml(File.ReadAllText(path, Encoding.UTF8));
+ foreach ((string kind, List list) in sections.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
+ {
+ foreach (WantedEntry entry in list)
+ {
+ items.Add(new JObject
+ {
+ ["kind"] = kind,
+ ["url"] = entry.Url,
+ ["title"] = entry.Title,
+ ["version_id"] = entry.VersionId,
+ });
+ }
+ }
+ return new JObject
+ {
+ ["success"] = true,
+ ["count"] = items.Count,
+ ["items"] = items,
+ ["path"] = path,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"wanted queue: {ex.Message}" };
+ }
+ }
+}
diff --git a/AssistentOllama.cs b/AssistentOllama.cs
new file mode 100644
index 0000000..65e269e
--- /dev/null
+++ b/AssistentOllama.cs
@@ -0,0 +1,328 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.WebSockets;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Utils;
+using SwarmUI.WebAPI;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Ollama transport: model listing, /api/chat calls and the two chat API endpoints.
+public partial class SwarmAssistentExtension
+{
+ const int DefaultNumCtxFallback = 16384;
+
+ public async Task AssistentListModels(Session session, string baseUrl)
+ {
+ string root = NormalizeBaseUrl(baseUrl);
+ try
+ {
+ using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags");
+ string body = await resp.Content.ReadAsStringAsync();
+ if (!resp.IsSuccessStatusCode)
+ {
+ return new JObject { ["error"] = $"Ollama /api/tags HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
+ }
+ JObject parsed = JObject.Parse(body);
+ JArray all = [];
+ foreach (JToken m in parsed["models"] as JArray ?? [])
+ {
+ string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? "";
+ if (!string.IsNullOrWhiteSpace(name))
+ {
+ all.Add(name);
+ }
+ }
+ JObject roles = Config?.LoadOllamaRoles() ?? new JObject();
+ HashSet chatSet = new(
+ (roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
+ StringComparer.OrdinalIgnoreCase);
+ HashSet memSet = new(
+ (roles["memory"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
+ StringComparer.OrdinalIgnoreCase);
+ // Heuristic fallbacks when sidecar missing
+ if (chatSet.Count == 0 && memSet.Count == 0)
+ {
+ foreach (JToken t in all)
+ {
+ string n = t.ToString();
+ if (LooksLikeEmbedModel(n))
+ {
+ memSet.Add(n);
+ }
+ else
+ {
+ chatSet.Add(n);
+ }
+ }
+ }
+ else
+ {
+ // Keep only tags that exist; anything unlabeled goes to chat if not memory
+ foreach (JToken t in all)
+ {
+ string n = t.ToString();
+ if (memSet.Contains(n) || LooksLikeEmbedModel(n))
+ {
+ memSet.Add(n);
+ chatSet.Remove(n);
+ }
+ else if (chatSet.Count == 0 || chatSet.Contains(n))
+ {
+ chatSet.Add(n);
+ }
+ else if (!memSet.Contains(n))
+ {
+ chatSet.Add(n);
+ }
+ }
+ }
+ JArray models = new(all.Select(t => t.ToString()).Where(n => chatSet.Contains(n) && !memSet.Contains(n) && !LooksLikeEmbedModel(n)));
+ JArray memoryModels = new(all.Select(t => t.ToString()).Where(n => memSet.Contains(n) || LooksLikeEmbedModel(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
+ if (memoryModels.Count == 0)
+ {
+ string fallback = Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text";
+ if (all.Any(t => string.Equals(t.ToString(), fallback, StringComparison.OrdinalIgnoreCase)
+ || t.ToString().StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)))
+ {
+ memoryModels.Add(all.Select(t => t.ToString()).First(n =>
+ string.Equals(n, fallback, StringComparison.OrdinalIgnoreCase)
+ || n.StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)));
+ }
+ }
+ return new JObject
+ {
+ ["success"] = true,
+ ["base_url"] = root,
+ ["models"] = models,
+ ["memory_models"] = memoryModels,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"Ollama unreachable at {root}: {ex.Message}" };
+ }
+ }
+
+ static bool LooksLikeEmbedModel(string name)
+ {
+ string n = (name ?? "").ToLowerInvariant();
+ return n.Contains("embed") || n.Contains("nomic") || n.Contains("bge-") || n.Contains("minilm") || n.Contains("e5-");
+ }
+
+ async Task<(string reply, JObject raw)> CallOllamaChat(
+ string root,
+ string modelName,
+ List ollamaMessages,
+ bool stream,
+ Func onDelta,
+ string personaId = null)
+ {
+ int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value()
+ ?? DefaultNumCtxFallback;
+ JObject payload = new()
+ {
+ ["model"] = modelName,
+ ["stream"] = stream,
+ ["messages"] = new JArray(ollamaMessages),
+ ["options"] = new JObject
+ {
+ ["num_ctx"] = numCtx,
+ },
+ ["keep_alive"] = "15m",
+ };
+ using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
+ using HttpRequestMessage req = new(HttpMethod.Post, $"{root}/api/chat") { Content = content };
+ using HttpResponseMessage resp = await HttpClient.SendAsync(req, stream
+ ? HttpCompletionOption.ResponseHeadersRead
+ : HttpCompletionOption.ResponseContentRead);
+ if (!resp.IsSuccessStatusCode)
+ {
+ string errBody = await resp.Content.ReadAsStringAsync();
+ throw new Exception($"Ollama /api/chat HTTP {(int)resp.StatusCode}: {Clip(errBody, 800)}");
+ }
+ if (!stream)
+ {
+ string body = await resp.Content.ReadAsStringAsync();
+ JObject parsed = JObject.Parse(body);
+ string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? "";
+ return (reply, parsed);
+ }
+ StringBuilder full = new();
+ await using Stream streamBody = await resp.Content.ReadAsStreamAsync();
+ using StreamReader reader = new(streamBody, Encoding.UTF8);
+ JObject last = null;
+ while (true)
+ {
+ string line = await reader.ReadLineAsync();
+ if (line is null)
+ {
+ break;
+ }
+ if (string.IsNullOrWhiteSpace(line))
+ {
+ continue;
+ }
+ JObject chunk = JObject.Parse(line);
+ last = chunk;
+ string delta = chunk["message"]?["content"]?.ToString() ?? "";
+ if (!string.IsNullOrEmpty(delta))
+ {
+ full.Append(delta);
+ if (onDelta is not null)
+ {
+ await onDelta(delta);
+ }
+ }
+ if (chunk["done"]?.Value() == true)
+ {
+ break;
+ }
+ }
+ return (full.ToString(), last ?? new JObject());
+ }
+
+ /// SwarmUI hands the whole request body over as the JObject param, so every field is read flat off it.
+ 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, out JArray skills)
+ {
+ JObject whole = raw ?? [];
+ if (string.IsNullOrWhiteSpace(baseUrl))
+ {
+ baseUrl = whole["base_url"]?.ToString() ?? whole["baseUrl"]?.ToString();
+ }
+ if (string.IsNullOrWhiteSpace(model))
+ {
+ model = whole["model"]?.ToString();
+ }
+ if (string.IsNullOrWhiteSpace(pack))
+ {
+ pack = whole["pack"]?.ToString();
+ }
+ if (whole["includeBase"] is not null)
+ {
+ includeBase = whole.Value("includeBase") ?? includeBase;
+ }
+ userMessages = whole["messages"] as JArray;
+ contextJson = whole["context_json"]?.ToString();
+ persona = whole["persona"]?.ToString() ?? "neutral";
+ skills = whole["skills"] as JArray;
+ }
+
+ /// Shared validation for both chat endpoints. Returns an error message, or null when the request is usable.
+ static string ValidateChatRequest(string modelName, JArray userMessages)
+ {
+ if (string.IsNullOrWhiteSpace(modelName))
+ {
+ return "model is required";
+ }
+ if (userMessages is null || userMessages.Count == 0)
+ {
+ return "messages required";
+ }
+ return null;
+ }
+
+ /// 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, out string persona, out JArray skills);
+ string root = NormalizeBaseUrl(baseUrl);
+ string modelName = (model ?? "").Trim();
+ string invalid = ValidateChatRequest(modelName, userMessages);
+ if (invalid is not null)
+ {
+ return new JObject { ["error"] = invalid };
+ }
+ string packName = (pack ?? "write_prompt").Trim();
+ string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
+ try
+ {
+ (string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
+ session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
+ return new JObject
+ {
+ ["success"] = true,
+ ["reply"] = reply,
+ ["model"] = modelName,
+ ["pack"] = packName,
+ ["persona"] = persona,
+ ["raw"] = parsed,
+ ["civitai_results"] = civitai,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" };
+ }
+ }
+
+ /// 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, out string persona, out JArray skills);
+ string root = NormalizeBaseUrl(baseUrl);
+ string modelName = (model ?? "").Trim();
+ string invalid = ValidateChatRequest(modelName, userMessages);
+ if (invalid is not null)
+ {
+ await ws.SendJson(new JObject { ["error"] = invalid }, API.WebsocketTimeout);
+ return null;
+ }
+ string packName = (pack ?? "write_prompt").Trim();
+ string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
+ try
+ {
+ if (ws.State == WebSocketState.Open)
+ {
+ await ws.SendJson(new JObject
+ {
+ ["phase"] = "waiting_ollama",
+ ["notice"] = "Loading model into GPU…",
+ }, API.WebsocketTimeout);
+ }
+ async Task OnDelta(string delta)
+ {
+ if (ws.State == WebSocketState.Open)
+ {
+ await ws.SendJson(new JObject { ["delta"] = delta }, API.WebsocketTimeout);
+ }
+ }
+ async Task OnHopStart(int hop)
+ {
+ if (ws.State == WebSocketState.Open && hop > 0)
+ {
+ await ws.SendJson(new JObject
+ {
+ ["clear_stream"] = true,
+ ["hop"] = hop + 1,
+ ["notice"] = "Civitai search done — refining…",
+ }, API.WebsocketTimeout);
+ }
+ }
+ (string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
+ session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
+ await ws.SendJson(new JObject
+ {
+ ["success"] = true,
+ ["done"] = true,
+ ["reply"] = reply,
+ ["model"] = modelName,
+ ["pack"] = packName,
+ ["persona"] = persona,
+ ["raw"] = parsed,
+ ["civitai_results"] = civitai,
+ }, API.WebsocketTimeout);
+ }
+ catch (Exception ex)
+ {
+ await ws.SendJson(new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" }, API.WebsocketTimeout);
+ }
+ return null;
+ }
+}
diff --git a/AssistentPatch.cs b/AssistentPatch.cs
new file mode 100644
index 0000000..92ec4a8
--- /dev/null
+++ b/AssistentPatch.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Text.RegularExpressions;
+using Newtonsoft.Json.Linq;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Parsing and normalization of the JSON patch the model emits inside fenced code blocks.
+public partial class SwarmAssistentExtension
+{
+ static readonly Regex JsonFenceRe = new(@"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ static readonly string[] PatchKeys =
+ [
+ "prompt", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler",
+ "actions", "search_query", "civitai_query",
+ "use_init_image", "clear_init_image", "init_creativity", "denoise",
+ "use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
+ "look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
+ "snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
+ "creativity", "intensity", "complexity", "movement",
+ "clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
+ ];
+
+ static bool HasValue(JObject obj, string key)
+ {
+ JToken token = obj?[key];
+ return token is not null && token.Type != JTokenType.Null;
+ }
+
+ /// Maps legacy/alias patch fields onto their canonical names. Aliases are kept so older consumers still work.
+ public static JObject NormalizePatch(JObject patch)
+ {
+ if (patch is null)
+ {
+ return null;
+ }
+ if (!HasValue(patch, "search_query") && HasValue(patch, "civitai_query"))
+ {
+ patch["search_query"] = patch["civitai_query"];
+ }
+ if (!HasValue(patch, "init_creativity") && HasValue(patch, "denoise"))
+ {
+ patch["init_creativity"] = patch["denoise"];
+ }
+ if (!HasValue(patch, "look_at"))
+ {
+ if (HasValue(patch, "vision_from"))
+ {
+ patch["look_at"] = patch["vision_from"];
+ }
+ else if (HasValue(patch, "vision_slots"))
+ {
+ patch["look_at"] = patch["vision_slots"];
+ }
+ }
+ return patch;
+ }
+
+ static JObject TryParsePatch(string reply)
+ {
+ if (string.IsNullOrWhiteSpace(reply))
+ {
+ return null;
+ }
+ foreach (Match match in JsonFenceRe.Matches(reply))
+ {
+ string raw = match.Groups[1].Value.Trim();
+ try
+ {
+ JObject obj = JObject.Parse(raw);
+ if (obj is not null && Array.Exists(PatchKeys, k => obj[k] is not null))
+ {
+ return NormalizePatch(obj);
+ }
+ }
+ catch
+ {
+ // not json
+ }
+ }
+ return null;
+ }
+
+ static string ExtractSearchQuery(JObject patch)
+ {
+ if (patch is null)
+ {
+ return null;
+ }
+ string q = (patch["search_query"] ?? patch["civitai_query"])?.ToString()?.Trim();
+ return string.IsNullOrWhiteSpace(q) ? null : q;
+ }
+
+ static bool WantsCivitaiSearch(JObject patch)
+ {
+ return !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch));
+ }
+}
diff --git a/AssistentPersist.cs b/AssistentPersist.cs
new file mode 100644
index 0000000..4de6d2a
--- /dev/null
+++ b/AssistentPersist.cs
@@ -0,0 +1,320 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Utils;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Disk persistence for chat sessions and UI state under DataRoot()/Assistent/.
+/// Chats survive browser storage wipes and follow the data volume across gpu-rent VMs.
+public partial class SwarmAssistentExtension
+{
+ const int MaxChatsOnDisk = 60;
+ const int MaxChatMessagesOnDisk = 40;
+ const int MaxChatMessageChars = 4000;
+
+ static readonly Regex ChatIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$", RegexOptions.Compiled);
+
+ /// UI-state keys accepted from the browser — anything else is dropped.
+ static readonly string[] UiStateKeys =
+ [
+ "pack", "persona", "auto_vision", "auto_apply", "auto_generate", "auto_critique",
+ "auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab",
+ ];
+
+ string AssistentDataDir() => Path.Combine(DataRoot(), "Assistent");
+
+ string AssistentChatsDir() => Path.Combine(AssistentDataDir(), "chats");
+
+ string AssistentUiStatePath() => Path.Combine(AssistentDataDir(), "ui-state.json");
+
+ static string SafeChatId(string id)
+ {
+ string s = (id ?? "").Trim();
+ return ChatIdRe.IsMatch(s) ? s : null;
+ }
+
+ string ChatFilePath(string id)
+ {
+ string safe = SafeChatId(id);
+ return safe is null ? null : Path.Combine(AssistentChatsDir(), $"{safe}.json");
+ }
+
+ JObject ReadChatFile(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
+ {
+ return null;
+ }
+ try
+ {
+ JObject chat = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
+ string id = SafeChatId(chat["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(path));
+ if (id is null)
+ {
+ return null;
+ }
+ chat["id"] = id;
+ return chat;
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentPersist read {path}: {ex.Message}");
+ return null;
+ }
+ }
+
+ static JObject ChatSummary(JObject chat)
+ {
+ JArray messages = chat["messages"] as JArray ?? [];
+ return new JObject
+ {
+ ["id"] = chat["id"],
+ ["title"] = chat["title"]?.ToString() ?? "Новый чат",
+ ["createdAt"] = chat["createdAt"] ?? 0,
+ ["updatedAt"] = chat["updatedAt"] ?? 0,
+ ["messages_count"] = messages.Count,
+ ["params"] = chat["params"],
+ };
+ }
+
+ List LoadAllChats()
+ {
+ string dir = AssistentChatsDir();
+ if (!Directory.Exists(dir))
+ {
+ return [];
+ }
+ List chats = [];
+ foreach (string file in Directory.EnumerateFiles(dir, "*.json"))
+ {
+ JObject chat = ReadChatFile(file);
+ if (chat is not null)
+ {
+ chats.Add(chat);
+ }
+ }
+ return chats
+ .OrderByDescending(c => c["updatedAt"]?.Value() ?? 0)
+ .ToList();
+ }
+
+ /// All chats on disk, newest first. Pass with_messages to get full transcripts.
+ public async Task AssistentListChats(Session session, bool with_messages = false, int limit = MaxChatsOnDisk)
+ {
+ await Task.CompletedTask;
+ try
+ {
+ List chats = LoadAllChats();
+ int take = Math.Clamp(limit, 1, MaxChatsOnDisk);
+ JArray list = [];
+ foreach (JObject chat in chats.Take(take))
+ {
+ list.Add(with_messages ? chat : ChatSummary(chat));
+ }
+ return new JObject
+ {
+ ["success"] = true,
+ ["chats"] = list,
+ ["total"] = chats.Count,
+ ["path"] = AssistentChatsDir(),
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"chats list: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentGetChat(Session session, string id)
+ {
+ await Task.CompletedTask;
+ string path = ChatFilePath(id);
+ if (path is null)
+ {
+ return new JObject { ["error"] = "valid id required" };
+ }
+ JObject chat = ReadChatFile(path);
+ return new JObject
+ {
+ ["success"] = true,
+ ["id"] = SafeChatId(id),
+ ["found"] = chat is not null,
+ ["chat"] = chat,
+ };
+ }
+
+ /// Writes one chat to Assistent/chats/<id>.json.
+ /// SwarmUI hands the whole request body to a JObject param, so messages (array)
+ /// and params (object) are read out of .
+ public async Task AssistentSaveChat(Session session, string id, string title, JObject raw)
+ {
+ await Task.CompletedTask;
+ string safe = SafeChatId(id);
+ if (safe is null)
+ {
+ return new JObject { ["error"] = "valid id required" };
+ }
+ JArray messages = raw?["messages"] as JArray ?? [];
+ JObject chatParams = raw?["params"] as JObject;
+ JArray trimmed = [];
+ foreach (JToken msg in messages.Skip(Math.Max(0, messages.Count - MaxChatMessagesOnDisk)))
+ {
+ if (msg is not JObject mo)
+ {
+ continue;
+ }
+ JObject copy = new()
+ {
+ ["role"] = mo["role"]?.ToString() ?? "user",
+ ["content"] = Clip(mo["content"]?.ToString() ?? "", MaxChatMessageChars),
+ };
+ if (!string.IsNullOrWhiteSpace(mo["persona"]?.ToString()))
+ {
+ copy["persona"] = mo["persona"];
+ }
+ if (!string.IsNullOrWhiteSpace(mo["pack"]?.ToString()))
+ {
+ copy["pack"] = mo["pack"];
+ }
+ trimmed.Add(copy);
+ }
+
+ long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ string path = ChatFilePath(safe);
+ JObject existing = ReadChatFile(path);
+ JObject chat = new()
+ {
+ ["id"] = safe,
+ ["title"] = string.IsNullOrWhiteSpace(title) ? (existing?["title"]?.ToString() ?? "Новый чат") : title.Trim(),
+ ["createdAt"] = raw?["createdAt"]?.Value() ?? existing?["createdAt"]?.Value() ?? now,
+ ["updatedAt"] = raw?["updatedAt"]?.Value() ?? now,
+ ["messages"] = trimmed,
+ ["params"] = chatParams ?? existing?["params"],
+ };
+ try
+ {
+ Directory.CreateDirectory(AssistentChatsDir());
+ File.WriteAllText(path, chat.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
+ PruneChatsOnDisk();
+ return new JObject { ["success"] = true, ["id"] = safe, ["path"] = path };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"chat save: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentDeleteChat(Session session, string id)
+ {
+ await Task.CompletedTask;
+ string path = ChatFilePath(id);
+ if (path is null)
+ {
+ return new JObject { ["error"] = "valid id required" };
+ }
+ try
+ {
+ bool existed = File.Exists(path);
+ if (existed)
+ {
+ File.Delete(path);
+ }
+ return new JObject { ["success"] = true, ["deleted"] = existed, ["id"] = SafeChatId(id) };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"chat delete: {ex.Message}" };
+ }
+ }
+
+ void PruneChatsOnDisk()
+ {
+ try
+ {
+ List chats = LoadAllChats();
+ if (chats.Count <= MaxChatsOnDisk)
+ {
+ return;
+ }
+ foreach (JObject stale in chats.Skip(MaxChatsOnDisk))
+ {
+ string path = ChatFilePath(stale["id"]?.ToString());
+ if (path is not null && File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentPersist prune: {ex.Message}");
+ }
+ }
+
+ public async Task AssistentGetUiState(Session session)
+ {
+ await Task.CompletedTask;
+ string path = AssistentUiStatePath();
+ if (!File.Exists(path))
+ {
+ return new JObject { ["success"] = true, ["ui_state"] = null };
+ }
+ try
+ {
+ return new JObject
+ {
+ ["success"] = true,
+ ["ui_state"] = JObject.Parse(File.ReadAllText(path, Encoding.UTF8)),
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"ui-state.json: {ex.Message}" };
+ }
+ }
+
+ /// Persists the whitelisted UI preferences. Reads ui_state from the body,
+ /// falling back to the flat body for convenience.
+ public async Task AssistentSaveUiState(Session session, JObject raw)
+ {
+ await Task.CompletedTask;
+ JObject source = raw?["ui_state"] as JObject ?? raw;
+ if (source is null)
+ {
+ return new JObject { ["error"] = "ui_state required" };
+ }
+ JObject clean = new();
+ foreach (string key in UiStateKeys)
+ {
+ JToken value = source[key];
+ if (value is null || value.Type == JTokenType.Null)
+ {
+ continue;
+ }
+ clean[key] = value.DeepClone();
+ }
+ if (clean.Count == 0)
+ {
+ return new JObject { ["error"] = "ui_state has no known keys" };
+ }
+ clean["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
+ try
+ {
+ Directory.CreateDirectory(AssistentDataDir());
+ string path = AssistentUiStatePath();
+ File.WriteAllText(path, clean.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
+ return new JObject { ["success"] = true, ["path"] = path };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"ui-state save: {ex.Message}" };
+ }
+ }
+}
diff --git a/AssistentVram.cs b/AssistentVram.cs
new file mode 100644
index 0000000..9843c10
--- /dev/null
+++ b/AssistentVram.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+using SwarmUI.Utils;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// VRAM handover between Ollama and the image backend: park the chat model before
+/// Generate, warm it again once the user is back in the chat. Embed / memory models are never
+/// parked — they are tiny and reloading them stalls every retrieve.
+public partial class SwarmAssistentExtension
+{
+ const string WarmKeepAlive = "15m";
+
+ /// Unloads the chat model from VRAM (keep_alive: 0) so Krea 2 gets the whole GPU.
+ public async Task AssistentParkLlm(Session session, string baseUrl, string model)
+ {
+ string root = NormalizeBaseUrl(baseUrl);
+ string name = (model ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return new JObject { ["error"] = "model is required" };
+ }
+ if (LooksLikeEmbedModel(name))
+ {
+ return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "memory model — never parked" };
+ }
+ JObject generate = new()
+ {
+ ["model"] = name,
+ ["prompt"] = "",
+ ["stream"] = false,
+ ["keep_alive"] = 0,
+ };
+ (bool ok, string body) = await PostOllamaJson(root, "/api/generate", generate);
+ if (!ok)
+ {
+ // Older Ollama builds only unload through /api/chat.
+ JObject chat = new()
+ {
+ ["model"] = name,
+ ["messages"] = new JArray(),
+ ["stream"] = false,
+ ["keep_alive"] = 0,
+ };
+ (ok, body) = await PostOllamaJson(root, "/api/chat", chat);
+ }
+ if (!ok)
+ {
+ Logs.Debug($"AssistentParkLlm {name}: {Clip(body, 200)}");
+ return new JObject { ["success"] = true, ["parked"] = false, ["note"] = Clip(body, 200) };
+ }
+ return new JObject { ["success"] = true, ["parked"] = true, ["model"] = name, ["base_url"] = root };
+ }
+
+ /// Single-token chat so the model is resident again by the time the user types.
+ public async Task AssistentWarmLlm(Session session, string baseUrl, string model, string persona = null)
+ {
+ string root = NormalizeBaseUrl(baseUrl);
+ string name = (model ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ return new JObject { ["error"] = "model is required" };
+ }
+ if (LooksLikeEmbedModel(name))
+ {
+ return new JObject { ["success"] = true, ["warmed"] = false, ["skipped"] = "memory model" };
+ }
+ int numCtx = CfgInt("num_ctx", DefaultNumCtxFallback);
+ JObject payload = new()
+ {
+ ["model"] = name,
+ ["stream"] = false,
+ ["messages"] = new JArray
+ {
+ new JObject { ["role"] = "user", ["content"] = "ok" },
+ },
+ ["options"] = new JObject
+ {
+ ["num_ctx"] = numCtx,
+ ["num_predict"] = 1,
+ },
+ ["keep_alive"] = WarmKeepAlive,
+ };
+ (bool ok, string body) = await PostOllamaJson(root, "/api/chat", payload);
+ if (!ok)
+ {
+ Logs.Debug($"AssistentWarmLlm {name}: {Clip(body, 200)}");
+ return new JObject { ["success"] = true, ["warmed"] = false, ["note"] = Clip(body, 200) };
+ }
+ return new JObject
+ {
+ ["success"] = true,
+ ["warmed"] = true,
+ ["model"] = name,
+ ["num_ctx"] = numCtx,
+ ["keep_alive"] = WarmKeepAlive,
+ };
+ }
+
+ static async Task<(bool ok, string body)> PostOllamaJson(string root, string route, JObject payload)
+ {
+ try
+ {
+ using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
+ using HttpResponseMessage resp = await HttpClient.PostAsync($"{root}{route}", content);
+ string body = await resp.Content.ReadAsStringAsync();
+ return (resp.IsSuccessStatusCode, resp.IsSuccessStatusCode ? body : $"HTTP {(int)resp.StatusCode}: {body}");
+ }
+ catch (Exception ex)
+ {
+ return (false, ex.Message);
+ }
+ }
+}
diff --git a/AssistentWanted.cs b/AssistentWanted.cs
new file mode 100644
index 0000000..dea1513
--- /dev/null
+++ b/AssistentWanted.cs
@@ -0,0 +1,184 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Queue of models the assistant wants downloaded (merged into gpu-rent models.yaml on next up/capture).
+public partial class SwarmAssistentExtension
+{
+ string WantedModelsPath() => Path.Combine(DataRoot(), ".gpu-rent-wanted-models.yaml");
+
+ string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards");
+
+ 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();
+ }
+}
diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md
index a6cdc4b..8baae90 100644
--- a/Config/_base/core/core.md
+++ b/Config/_base/core/core.md
@@ -11,7 +11,7 @@ When instructions conflict, apply this order (highest wins):
3. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
4. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged into it.
5. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
-6. **`memory_hits` (vector RAG)** — notes, pitfalls, LoRA blurbs. Never override exact numbers or the user’s param request.
+6. **`memory_hits` (vector RAG)** — notes, pitfalls, LoRA blurbs. Shared hits apply to every persona; personal hits are this persona only and overwrite shared on the same kind+key. Never override exact numbers or the user’s param request.
7. Guesses — last resort only.
Exact = encyclopedia of defaults. RAG = soft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match exact (or session_exact) and the user did not ask to change them.
@@ -25,7 +25,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words.
- `session_exact` / `recommended_params` — session overrides and defaults (Exact KV is in the system block above).
-- `memory_hits` are retrieved notes (LoRA tips, pitfalls). Trust them over guesses, but **not** over Exact or the user.
+- `memory_hits` are retrieved notes (LoRA tips, pitfalls). Each hit has `scope` (`shared`|`personal`). Trust them over guesses, but **not** over Exact or the user.
- `has_vision_image` — if false, do not invent what the image looks like; emit `look_at` first when you need to see it.
- `model_cards` for **enabled** models beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`.
- `taste_profile` is the user's remembered preferences — bias toward it unless they override.
@@ -77,7 +77,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
"pack": null,
"actions": ["generate"],
"search_query": null,
- "memories": [{"kind": "lora", "key": "name", "text": "fact"}],
+ "memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
"notes": "one-line why"
}
```
@@ -91,13 +91,13 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
- `pack` — switch active prompt pack for a follow-up hop.
- Do not invent model or LoRA filenames.
-- Memory: `actions` may include `memory_upsert` or `memory_forget` with `memories: [{kind,key,text}]`.
+- Memory: `actions` may include `memory_upsert` or `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal (this persona). `"scope":"shared"` is visible to all personas; personal never copies into shared.
### Actions (auto-safe)
- `"generate"` — after Apply, start generation. When the user explicitly asks to generate, always include this; the UI applies silently (no Apply-button strip).
- `"search_civitai"` — Civitai search; user Confirms downloads.
- `"interrupt"` — stop generation.
-- `"memory_upsert"` / `"memory_forget"` — write or delete facts in vector memory.
+- `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store).
- `look_at: ["generate", "ref1"]` — vision hop.
- Pure Q&A with no change: omit the JSON patch.
diff --git a/Config/_base/skills/memory.md b/Config/_base/skills/memory.md
index 08ddeeb..e69c78c 100644
--- a/Config/_base/skills/memory.md
+++ b/Config/_base/skills/memory.md
@@ -3,7 +3,7 @@
You have two memory layers:
1. **Exact memory** (`## Exact memory` + live `exact` / `session_exact`) — canonical KV defaults (generation params, aspect table, architecture facts). Always prefer Exact over RAG for numbers and defaults.
-2. **Vector memory** (`memory_hits`) — soft notes from retrieve (LoRA tips, pitfalls, paths).
+2. **Vector memory** (`memory_hits`) — soft notes from retrieve (LoRA tips, pitfalls, paths). Hits are **shared + this persona**. `scope: "personal"` overwrites `scope: "shared"` on the same `kind`+`key`. Other personas never see your personal rows.
## Priority
@@ -13,7 +13,9 @@ User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hit
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
- Bad paths / pitfalls you discovered this session.
-- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…" }]`.
+- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…", "scope": "personal"|"shared" }]`.
+- Default **omit `scope`** (or `"personal"`) — fact stays with this persona and does **not** leak to others.
+- Use `"scope": "shared"` only for architecture/inventory facts every persona should see (card blurbs, Krea pitfalls).
## When not to write
@@ -21,4 +23,4 @@ User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hit
- Do not dump the full inventory — retrieve already surfaces relevant blurbs.
- Do not store the user's taste profile (that is `taste_profile` / taste.json).
- Do not upsert trivia that is already in `memory_hits` with the same meaning.
-- `memory_forget` only when a fact is wrong or obsolete.
+- `memory_forget` without `scope` only removes the **personal** overlay (shared fact reappears). Use `"scope": "shared"` to delete a shared row.
diff --git a/Config/personas/cinema/memory-seed/framing.json b/Config/personas/cinema/memory-seed/framing.json
new file mode 100644
index 0000000..cfb1b19
--- /dev/null
+++ b/Config/personas/cinema/memory-seed/framing.json
@@ -0,0 +1,8 @@
+[
+ {
+ "kind": "note",
+ "key": "cinema_framing",
+ "tags": ["cinema", "framing"],
+ "text": "Cinema persona: prefer establishing wides, motivated lamp practicals, and 2.39/16:9 cinematic framing. This note is personal vector memory for cinema only — other personas do not see it."
+ }
+]
diff --git a/README.md b/README.md
index cbdecc4..58bc092 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,13 @@
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
-**Version 0.7.6** — Bugfix: clear/new abort in-flight chat; Send blocked while generating; Interrupt always drops stream bubble; patch-clear resets lastPatch.
+**Version 0.8.1** — Chats live on disk (`Assistent/chats/`), the chat model is **parked out of VRAM** before every Generate, memory + wanted queue are editable in ⚙, Ollama health sits in the chat header. Vector memory is shared + personal: personal never leaks into shared; shared is visible to every persona; personal overwrites the same kind+key.
## Layout
-- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`)
+- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`); **Посмотри результат** attaches the finished frame and asks for a verdict
- **Splitter:** drag to resize panes
-- **Right:** Chat | Cards; persona / pack / Ollama chat model; settings gear (memory model + skills)
+- **Right:** Chat | Cards; persona / pack / Ollama chat model; **Ollama health** badge; settings gear (memory model + skills + **Память**)
- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
## Config (bundled + overlay)
@@ -16,12 +16,23 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
```
Config/
_base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
- personas// # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / overrides
+ personas// # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / memory-seed / overrides
```
-Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same layout, plus `settings.json`, `taste.json`, `personas.json` (legacy prompt overlay), `ollama-roles.json`, `memory/assistent.sqlite`.
+Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder layout as `Config/`, i.e. drop `_base/…` and `personas//…` files to override any bundled preset. Plus this extension's own state:
-Copy `personas/cinema/` → `noir/`, edit only differing JSON files.
+```
+Assistent/
+ _base/ personas// # overlay presets — same names as Config/, sparse
+ settings.json # embed_model, base_url, per-persona skills
+ ui-state.json # pack / persona / auto_* / pane_width / models — seeds a fresh browser
+ taste.json # learned taste profile (wins over localStorage)
+ chats/.json # chat history, newest 60 kept
+ ollama-roles.json # chat vs memory model tags
+ memory/assistent.sqlite # vector store (shared + per-persona)
+```
+
+Copy `personas/cinema/` → `noir/`, edit only differing JSON files. Persona prompt overrides belong in `personas//` — the old flat `personas.json` is legacy and only read when no overlay folder exists for that id.
## Exact memory (KV)
@@ -33,18 +44,34 @@ Copy `personas/cinema/` → `noir/`, edit only differing JSON files.
## Vector memory
+Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
+
+- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
+- **Personal** — `Config/personas//memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
+- Retrieve = shared ∪ this persona (and `extends` parents). Same `kind`+`key`: personal overwrites parent overwrites shared. Forget without `scope` only drops the personal overlay.
- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
-- First chat seeds `Config/_base/memory-seed/` (pointers + pitfalls; numbers live in Exact)
-- Agents upsert via patch `memory_upsert` / `memory_forget`
-- Cards ingest on save; retrieve → `memory_hits` in live context (inventory slimmed)
- Soft notes only — Exact and the user beat RAG for params
+- ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
+
+## Chats on disk
+
+- Every chat is written to `Assistent/chats/.json` (messages + a Generate params snapshot), so History survives a cleared browser and follows the data volume across VMs
+- localStorage stays as a fast cache; on first run with an empty `chats/` the old `swarm_assistent_chats_v1` store is migrated up once
+- `ui-state.json` seeds a **fresh** browser only — anything already in localStorage wins, and `auto_download` is never restored as on
+
+## VRAM handover
+
+- Before every Generate the chat model is unloaded (`keep_alive: 0`) so Krea 2 gets the whole GPU
+- Back in the Chat tab it is warmed again with a 1-token request (`keep_alive 15m`, `num_ctx` from `assistant.json`)
+- Embed / memory models are never parked — reloading them would stall every retrieve
## UX
- **Send to Assistent** under Generate/History → Ref + Assistent tab
- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
-- Civitai Confirm required (unless auto-download)
+- **Посмотри результат** / auto-critique wait for a real Generate frame — model previews and unfinished batches are skipped
+- Civitai Confirm required (unless auto-download); queued-but-missing models show a `⏳ wanted` badge in Cards
### Slash commands (client-side, no LLM)
@@ -103,10 +130,14 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentGetPacks` | Prompt pack texts |
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
-| `AssistentEnqueueWanted` | Wanted YAML queue |
+| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
| `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) |
+| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
+| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/.json` |
+| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` |
+| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
## License
diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs
index 11c0640..6db5004 100644
--- a/SwarmAssistentExtension.cs
+++ b/SwarmAssistentExtension.cs
@@ -1,25 +1,19 @@
using System;
-using System.Collections.Generic;
using System.IO;
-using System.Linq;
-using System.Net.Http;
-using System.Net.Http.Headers;
-using System.Net.WebSockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
-using FreneticUtilities.FreneticExtensions;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Core;
-using SwarmUI.Text2Image;
using SwarmUI.Utils;
using SwarmUI.WebAPI;
+using System.Net.Http;
namespace Mrleo1nid.SwarmAssistent;
/// Krea 2 collaborative assistant: Ollama chat + vision + prompt/LoRA/params patches + Generate/Civitai.
-public class SwarmAssistentExtension : Extension
+public partial class SwarmAssistentExtension : Extension
{
public static PermInfo PermUse = Permissions.Register(new(
"swarm_assistent_use",
@@ -33,23 +27,17 @@ public class SwarmAssistentExtension : Extension
public AssistentConfig Config;
public AssistentMemory Memory;
- const int MaxCivitaiHopsFallback = 2;
- const int MaxLorasInInventoryFallback = 150;
- const int MaxWildcardsInInventoryFallback = 80;
- const int MaxCheckpointsInInventoryFallback = 60;
- const int InventoryBlurbMaxFallback = 140;
- const int DefaultNumCtxFallback = 16384;
-
- static readonly Regex JsonFenceRe = new(@"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
public override void OnPreInit()
{
+ ScriptFiles.Add("Assets/assistent.api.js");
+ ScriptFiles.Add("Assets/assistent.patch.js");
+ ScriptFiles.Add("Assets/assistent.persist.js");
ScriptFiles.Add("Assets/assistent.js");
StyleSheetFiles.Add("Assets/assistent.css");
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT";
- Version = "0.7.6";
+ Version = "0.8.1";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
}
@@ -74,7 +62,19 @@ public class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentSaveTaste, true, PermUse);
API.RegisterAPICall(AssistentChat, true, PermUse);
API.RegisterAPICall(AssistentChatWS, true, PermUse);
- Logs.Init("Swarm Assistent extension loaded (Config presets + vector memory)");
+ API.RegisterAPICall(AssistentListChats, false, PermUse);
+ API.RegisterAPICall(AssistentGetChat, false, PermUse);
+ API.RegisterAPICall(AssistentSaveChat, true, PermUse);
+ API.RegisterAPICall(AssistentDeleteChat, true, PermUse);
+ API.RegisterAPICall(AssistentGetUiState, false, PermUse);
+ API.RegisterAPICall(AssistentSaveUiState, true, PermUse);
+ API.RegisterAPICall(AssistentParkLlm, true, PermUse);
+ API.RegisterAPICall(AssistentWarmLlm, true, PermUse);
+ API.RegisterAPICall(AssistentListMemory, false, PermUse);
+ API.RegisterAPICall(AssistentUpsertMemory, true, PermUse);
+ API.RegisterAPICall(AssistentForgetMemory, true, PermUse);
+ API.RegisterAPICall(AssistentListWanted, false, PermUse);
+ Logs.Init("Swarm Assistent extension loaded (disk chats + park LLM + memory UI)");
}
int CfgInt(string key, int fallback)
@@ -98,6 +98,15 @@ public class SwarmAssistentExtension : Extension
return text[..max] + "…";
}
+ static string CollapseWs(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ return "";
+ }
+ return Regex.Replace(text.Trim(), @"\s+", " ");
+ }
+
public static string NormalizeBaseUrl(string raw)
{
string url = (raw ?? "").Trim();
@@ -108,109 +117,36 @@ public class SwarmAssistentExtension : Extension
return url.TrimEnd('/');
}
+ 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 TasteJsonPath() => Path.Combine(DataRoot(), "Assistent", "taste.json");
+
public string ReadPackFile(string name)
{
return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name);
}
- public async Task AssistentListModels(Session session, string baseUrl)
- {
- string root = NormalizeBaseUrl(baseUrl);
- try
- {
- using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags");
- string body = await resp.Content.ReadAsStringAsync();
- if (!resp.IsSuccessStatusCode)
- {
- return new JObject { ["error"] = $"Ollama /api/tags HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
- }
- JObject parsed = JObject.Parse(body);
- JArray all = [];
- foreach (JToken m in parsed["models"] as JArray ?? [])
- {
- string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? "";
- if (!string.IsNullOrWhiteSpace(name))
- {
- all.Add(name);
- }
- }
- JObject roles = Config?.LoadOllamaRoles() ?? new JObject();
- HashSet chatSet = new(
- (roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
- StringComparer.OrdinalIgnoreCase);
- HashSet memSet = new(
- (roles["memory"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
- StringComparer.OrdinalIgnoreCase);
- // Heuristic fallbacks when sidecar missing
- if (chatSet.Count == 0 && memSet.Count == 0)
- {
- foreach (JToken t in all)
- {
- string n = t.ToString();
- if (LooksLikeEmbedModel(n))
- {
- memSet.Add(n);
- }
- else
- {
- chatSet.Add(n);
- }
- }
- }
- else
- {
- // Keep only tags that exist; anything unlabeled goes to chat if not memory
- foreach (JToken t in all)
- {
- string n = t.ToString();
- if (memSet.Contains(n) || LooksLikeEmbedModel(n))
- {
- memSet.Add(n);
- chatSet.Remove(n);
- }
- else if (chatSet.Count == 0 || chatSet.Contains(n))
- {
- chatSet.Add(n);
- }
- else if (!memSet.Contains(n))
- {
- chatSet.Add(n);
- }
- }
- }
- JArray models = new(all.Select(t => t.ToString()).Where(n => chatSet.Contains(n) && !memSet.Contains(n) && !LooksLikeEmbedModel(n)));
- JArray memoryModels = new(all.Select(t => t.ToString()).Where(n => memSet.Contains(n) || LooksLikeEmbedModel(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
- if (memoryModels.Count == 0)
- {
- string fallback = Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text";
- if (all.Any(t => string.Equals(t.ToString(), fallback, StringComparison.OrdinalIgnoreCase)
- || t.ToString().StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)))
- {
- memoryModels.Add(all.Select(t => t.ToString()).First(n =>
- string.Equals(n, fallback, StringComparison.OrdinalIgnoreCase)
- || n.StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)));
- }
- }
- return new JObject
- {
- ["success"] = true,
- ["base_url"] = root,
- ["models"] = models,
- ["memory_models"] = memoryModels,
- };
- }
- catch (Exception ex)
- {
- return new JObject { ["error"] = $"Ollama unreachable at {root}: {ex.Message}" };
- }
- }
-
- static bool LooksLikeEmbedModel(string name)
- {
- string n = (name ?? "").ToLowerInvariant();
- return n.Contains("embed") || n.Contains("nomic") || n.Contains("bge-") || n.Contains("minilm") || n.Contains("e5-");
- }
-
public async Task AssistentGetPacks(Session session, string persona = null)
{
await Task.CompletedTask;
@@ -266,33 +202,6 @@ public class SwarmAssistentExtension : Extension
return new JObject { ["success"] = true, ["path"] = Path.Combine(Config.OverlayRoot, "settings.json") };
}
- 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 async Task AssistentListPersonas(Session session)
{
await Task.CompletedTask;
@@ -317,350 +226,6 @@ public class SwarmAssistentExtension : Extension
};
}
- 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);
- _ = IngestCardToMemory(card, name);
- 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 rawVid = card["version_id"]?.ToString() ?? "draft";
- string vid = Regex.IsMatch(rawVid, @"^\d+$") ? rawVid : "draft";
- string draft = Path.Combine(WantedCardsDir(), $"{vid}.assistent.json");
- File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
- if (enqueue_wanted || !string.IsNullOrWhiteSpace(card["civitai_url"]?.ToString()))
- {
- await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value() ?? 0, card["title"]?.ToString() ?? name, card);
- }
- _ = IngestCardToMemory(card, name);
- return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
- }
-
- async Task IngestCardToMemory(JObject card, string name)
- {
- if (Memory is null || card is null)
- {
- return;
- }
- try
- {
- string kind = (card["kind"]?.ToString() ?? "lora").Trim().ToLowerInvariant();
- string key = (card["name"]?.ToString() ?? name ?? "").Trim();
- List bits = [];
- foreach (string field in new[] { "when", "avoid", "prompt_hint", "notes" })
- {
- string v = card[field]?.ToString();
- if (!string.IsNullOrWhiteSpace(v))
- {
- bits.Add($"{field}: {v.Trim()}");
- }
- }
- if (card["triggers"] is JArray tr)
- {
- string joined = string.Join(", ", tr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
- if (!string.IsNullOrWhiteSpace(joined))
- {
- bits.Add("triggers: " + joined);
- }
- }
- if (bits.Count == 0 || string.IsNullOrWhiteSpace(key))
- {
- return;
- }
- string text = $"{kind} {key}. " + string.Join(" ", bits);
- string baseUrl = NormalizeBaseUrl(Config.LoadSettings()["base_url"]?.ToString());
- string embedModel = Config.LoadSettings()["embed_model"]?.ToString()
- ?? Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString();
- await Memory.UpsertTextAsync(baseUrl, "card", key, text, "user", card, embedModel);
- }
- catch (Exception ex)
- {
- Logs.Debug($"IngestCardToMemory: {ex.Message}");
- }
- }
-
- 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();
- }
-
- string TasteJsonPath() => Path.Combine(DataRoot(), "Assistent", "taste.json");
-
public async Task AssistentGetTaste(Session session)
{
await Task.CompletedTask;
@@ -697,1283 +262,4 @@ public class SwarmAssistentExtension : Extension
File.WriteAllText(path, taste.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
return new JObject { ["success"] = true, ["path"] = path };
}
-
- public async Task AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0, bool fetch = false)
- {
- string set = SetNameForKind(kind);
- string weight = ModelWeightPath(set, name);
- JObject civitai = null;
- JArray exampleUrls = [];
- JArray previewUrls = [];
- bool hasSidecar = false;
- string fetchError = null;
- bool fetched = false;
-
- if (!string.IsNullOrWhiteSpace(weight))
- {
- string stem = Path.GetFileNameWithoutExtension(weight);
- string dir = Path.GetDirectoryName(weight);
- string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
- if (File.Exists(side))
- {
- hasSidecar = true;
- try
- {
- civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8));
- }
- catch
- {
- // ignore
- }
- }
- foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
- {
- string prev = Path.Combine(dir ?? "", stem + suffix);
- if (File.Exists(prev))
- {
- // Swarm View path — relative URL works in the same origin browser session.
- previewUrls.Add($"View/Models/{(kind == "lora" ? "Lora" : "Stable-Diffusion")}/{Path.GetFileName(prev)}");
- break;
- }
- }
- }
-
- if (civitai is not null)
- {
- if (version_id <= 0)
- {
- version_id = civitai["id"]?.Value() ?? 0;
- }
- CollectExampleUrls(civitai, exampleUrls);
- }
-
- string hash = null;
- string trigger = null;
- try
- {
- if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h)
- && (h.Models.TryGetValue(name, out T2IModel m)
- || h.Models.TryGetValue(name.Replace('\\', '/'), out m)))
- {
- trigger = m.Metadata?.TriggerPhrase;
- hash = m.Metadata?.Hash;
- }
- }
- catch
- {
- // ignore
- }
-
- if (fetch && civitai is null)
- {
- string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
- if (string.IsNullOrWhiteSpace(apiKey))
- {
- fetchError = "Civitai: нет ключа в User Settings";
- }
- else
- {
- try
- {
- JObject remote = null;
- if (version_id > 0)
- {
- remote = await FetchCivitaiModelVersion(apiKey, version_id);
- }
- if (remote is null && !string.IsNullOrWhiteSpace(hash))
- {
- string sha = hash.Trim().ToLowerInvariant();
- if (sha.StartsWith("sha256:"))
- {
- sha = sha["sha256:".Length..];
- }
- if (sha.Length == 64)
- {
- remote = await FetchCivitaiByHash(apiKey, sha);
- }
- else
- {
- fetchError ??= "Civitai: хеш модели не SHA256";
- }
- }
- if (remote is not null)
- {
- civitai = remote;
- fetched = true;
- version_id = remote["id"]?.Value() ?? version_id;
- CollectExampleUrls(remote, exampleUrls);
- if (!string.IsNullOrWhiteSpace(weight))
- {
- try
- {
- string stem = Path.GetFileNameWithoutExtension(weight);
- string dir = Path.GetDirectoryName(weight);
- string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
- File.WriteAllText(side, remote.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
- hasSidecar = true;
- }
- catch (Exception ex)
- {
- Logs.Debug($"AssistentGetCardMeta write sidecar: {ex.Message}");
- }
- }
- }
- else if (fetchError is null)
- {
- fetchError = string.IsNullOrWhiteSpace(hash)
- ? "Civitai: нет hash и version_id"
- : "Хеш не найден на Civitai";
- }
- }
- catch (Exception ex)
- {
- fetchError = $"Civitai: {ex.Message}";
- }
- }
- }
-
- JObject card = ReadCardObject(kind, name);
- return new JObject
- {
- ["success"] = true,
- ["kind"] = kind,
- ["name"] = name,
- ["version_id"] = version_id,
- ["trigger_phrase"] = trigger,
- ["has_card"] = card is not null,
- ["has_sidecar"] = hasSidecar,
- ["fetched"] = fetched,
- ["fetch_error"] = fetchError,
- ["card"] = card,
- ["civitai"] = civitai,
- ["example_urls"] = exampleUrls,
- ["preview_urls"] = previewUrls,
- ["weight_path"] = weight,
- ["hash"] = hash,
- };
- }
-
- static void CollectExampleUrls(JObject civitai, JArray exampleUrls)
- {
- if (civitai?["images"] is not JArray imgs)
- {
- return;
- }
- foreach (JToken img in imgs.Take(6))
- {
- string u = img?["url"]?.ToString();
- if (!string.IsNullOrWhiteSpace(u))
- {
- exampleUrls.Add(u);
- }
- }
- }
-
- async Task FetchCivitaiByHash(string apiKey, string sha)
- {
- string[] hosts = ["civitai.red", "civitai.com"];
- Exception last = null;
- foreach (string host in hosts)
- {
- try
- {
- string url = $"https://{host}/api/v1/model-versions/by-hash/{sha}";
- using HttpRequestMessage req = new(HttpMethod.Get, url);
- if (!string.IsNullOrWhiteSpace(apiKey))
- {
- req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
- }
- using HttpResponseMessage resp = await HttpClient.SendAsync(req);
- string body = await resp.Content.ReadAsStringAsync();
- if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
- {
- continue;
- }
- if (!resp.IsSuccessStatusCode)
- {
- last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
- if ((int)resp.StatusCode is 401 or 403)
- {
- throw last;
- }
- continue;
- }
- return JObject.Parse(body);
- }
- catch (Exception ex) when (ex is not HttpRequestException && ex.Message.Contains("401"))
- {
- throw;
- }
- catch (Exception ex)
- {
- last = ex;
- }
- }
- if (last is not null)
- {
- throw last;
- }
- return null;
- }
-
- async Task FetchCivitaiModelVersion(string apiKey, int versionId)
- {
- string[] hosts = ["civitai.red", "civitai.com"];
- Exception last = null;
- foreach (string host in hosts)
- {
- try
- {
- string url = $"https://{host}/api/v1/model-versions/{versionId}";
- using HttpRequestMessage req = new(HttpMethod.Get, url);
- if (!string.IsNullOrWhiteSpace(apiKey))
- {
- req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
- }
- using HttpResponseMessage resp = await HttpClient.SendAsync(req);
- string body = await resp.Content.ReadAsStringAsync();
- if (!resp.IsSuccessStatusCode)
- {
- last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
- if ((int)resp.StatusCode is 401 or 403)
- {
- throw last;
- }
- continue;
- }
- return JObject.Parse(body);
- }
- catch (Exception ex)
- {
- last = ex;
- if (ex.Message.Contains("401") || ex.Message.Contains("403"))
- {
- throw;
- }
- }
- }
- if (last is not null)
- {
- throw last;
- }
- return null;
- }
-
- /// Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
- /// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).
- public async Task AssistentListInventory(Session session, bool rescan = false)
- {
- 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
- .OrderByDescending(m => LooksLikeKreaArch(m))
- .ThenBy(m => m.Name)
- .Take(CfgInt("max_loras_inventory", MaxLorasInInventoryFallback)))
- {
- loras.Add(BuildInventoryModelEntry(model, "lora"));
- }
- }
-
- if (Program.T2IModelSets.TryGetValue("Stable-Diffusion", out T2IModelHandler ckptHandler))
- {
- foreach (T2IModel model in ckptHandler.Models.Values
- .OrderByDescending(m => LooksLikeKreaArch(m))
- .ThenBy(m => m.Name)
- .Take(CfgInt("max_checkpoints_inventory", MaxCheckpointsInInventoryFallback)))
- {
- checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint"));
- }
- }
-
- try
- {
- foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(CfgInt("max_wildcards_inventory", MaxWildcardsInInventoryFallback)))
- {
- wildcards.Add(new JObject { ["name"] = name });
- }
- }
- catch (Exception ex)
- {
- Logs.Debug($"AssistentListInventory wildcards: {ex.Message}");
- }
-
- bool hasCivitaiKey = !string.IsNullOrWhiteSpace(session.User.GetGenericData("civitai_api", "key"));
-
- return new JObject
- {
- ["success"] = true,
- ["loras"] = loras,
- ["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(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
- }
- }
- catch
- {
- // ignore bad card json
- }
- }
- if (string.IsNullOrWhiteSpace(blurb))
- {
- string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
- if (!string.IsNullOrWhiteSpace(raw))
- {
- blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
- }
- }
-
- 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 (!string.IsNullOrWhiteSpace(weight))
- {
- string stem = Path.GetFileNameWithoutExtension(weight);
- string dir = Path.GetDirectoryName(weight);
- string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
- entry["has_sidecar"] = File.Exists(side);
- foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
- {
- string prev = Path.Combine(dir ?? "", stem + suffix);
- if (File.Exists(prev))
- {
- string folder = kind == "lora" ? "Lora" : "Stable-Diffusion";
- entry["preview_url"] = $"View/Models/{folder}/{Path.GetFileName(prev)}";
- break;
- }
- }
- }
- else
- {
- entry["has_sidecar"] = false;
- }
- if (triggers is not null && triggers.Count > 0)
- {
- entry["triggers"] = triggers;
- }
- 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)
- {
- string q = (query ?? "").Trim();
- if (string.IsNullOrWhiteSpace(q))
- {
- return new JObject { ["error"] = "query is required" };
- }
- limit = Math.Clamp(limit, 1, 20);
- string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
- HashSet installedNames = CollectInstalledLoraNames();
- HashSet installedHashes = CollectInstalledLoraHashes();
-
- string[] hosts = ["civitai.red", "civitai.com"];
- Exception lastEx = null;
- foreach (string host in hosts)
- {
- try
- {
- string url = $"https://{host}/api/v1/models?limit={limit}&types=LORA&query={Uri.EscapeDataString(q)}";
- using HttpRequestMessage req = new(HttpMethod.Get, url);
- if (!string.IsNullOrWhiteSpace(apiKey))
- {
- req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
- }
- using HttpResponseMessage resp = await HttpClient.SendAsync(req);
- string body = await resp.Content.ReadAsStringAsync();
- if (!resp.IsSuccessStatusCode)
- {
- lastEx = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 200)}");
- continue;
- }
- JObject parsed = JObject.Parse(body);
- JArray items = parsed["items"] as JArray ?? [];
- JArray results = [];
- foreach (JToken item in items)
- {
- if (item is not JObject mo)
- {
- continue;
- }
- JObject card = BuildCivitaiCard(mo, installedNames, installedHashes);
- if (card is not null)
- {
- results.Add(card);
- }
- }
- // Prefer Krea-compatible first
- JArray sorted = new(results.OrderByDescending(t => LooksLikeKrea(t["base_model"]?.ToString())).ThenBy(t => t["name"]?.ToString()));
- return new JObject
- {
- ["success"] = true,
- ["query"] = q,
- ["host"] = host,
- ["results"] = sorted,
- ["has_civitai_key"] = !string.IsNullOrWhiteSpace(apiKey),
- };
- }
- catch (Exception ex)
- {
- lastEx = ex;
- }
- }
- return new JObject { ["error"] = $"Civitai search failed: {lastEx?.Message ?? "unknown"}" };
- }
-
- static bool LooksLikeKrea(string text) => !string.IsNullOrEmpty(text) && Regex.IsMatch(text, @"krea", RegexOptions.IgnoreCase);
-
- static HashSet CollectInstalledLoraNames()
- {
- HashSet names = new(StringComparer.OrdinalIgnoreCase);
- if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
- {
- return names;
- }
- foreach (T2IModel m in handler.Models.Values)
- {
- names.Add(m.Name);
- string leaf = m.Name.Replace('\\', '/').AfterLast('/');
- if (!string.IsNullOrEmpty(leaf))
- {
- names.Add(leaf);
- names.Add(Path.GetFileNameWithoutExtension(leaf));
- }
- }
- return names;
- }
-
- static HashSet CollectInstalledLoraHashes()
- {
- HashSet hashes = new(StringComparer.OrdinalIgnoreCase);
- if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
- {
- return hashes;
- }
- foreach (T2IModel m in handler.Models.Values)
- {
- string h = m.Metadata?.Hash;
- if (!string.IsNullOrWhiteSpace(h))
- {
- hashes.Add(h.Trim().ToLowerInvariant());
- }
- }
- return hashes;
- }
-
- static JObject BuildCivitaiCard(JObject model, HashSet installedNames, HashSet installedHashes)
- {
- string name = model["name"]?.ToString() ?? "";
- JArray versions = model["modelVersions"] as JArray;
- JObject ver = versions?.FirstOrDefault() as JObject;
- if (ver is null)
- {
- return null;
- }
- string baseModel = ver["baseModel"]?.ToString() ?? "";
- JArray trained = ver["trainedWords"] as JArray ?? [];
- List triggers = trained.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).Take(8).ToList();
- JObject file = null;
- foreach (JToken f in ver["files"] as JArray ?? [])
- {
- if (f is JObject fo && (fo["primary"]?.Value() == true || (fo["name"]?.ToString() ?? "").EndsWith(".safetensors", StringComparison.OrdinalIgnoreCase)))
- {
- file = fo;
- break;
- }
- }
- file ??= (ver["files"] as JArray)?.FirstOrDefault() as JObject;
- string downloadUrl = file?["downloadUrl"]?.ToString() ?? ver["downloadUrl"]?.ToString() ?? "";
- string fileName = file?["name"]?.ToString() ?? "";
- string sha = file?["hashes"]?["SHA256"]?.ToString() ?? file?["hashes"]?["AutoV2"]?.ToString() ?? "";
- string saveName = string.IsNullOrWhiteSpace(fileName)
- ? Regex.Replace(name, @"[^\w\-.]+", "_").Trim('_')
- : Path.GetFileNameWithoutExtension(fileName);
-
- bool already = false;
- if (!string.IsNullOrWhiteSpace(sha) && installedHashes.Contains(sha.Trim().ToLowerInvariant()))
- {
- already = true;
- }
- else if (installedNames.Contains(saveName) || installedNames.Contains(name) || installedNames.Contains(fileName))
- {
- already = true;
- }
-
- return new JObject
- {
- ["id"] = model["id"],
- ["version_id"] = ver["id"],
- ["name"] = name,
- ["base_model"] = baseModel,
- ["krea_likely"] = LooksLikeKrea(baseModel),
- ["triggers"] = new JArray(triggers),
- ["download_url"] = downloadUrl,
- ["file_name"] = saveName,
- ["sha256"] = sha,
- ["already_installed"] = already,
- ["n_sfw"] = model["nsfw"]?.Value() ?? false,
- };
- }
-
- List BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable skillIds = null)
- {
- List ollamaMessages = [];
- StringBuilder system = new();
- string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
-
- if (includeBase)
- {
- string core = Config.LoadCorePrompt(pid);
- if (!string.IsNullOrWhiteSpace(core))
- {
- system.AppendLine(core);
- }
- }
-
- JObject exact = Config.LoadExactForPrompt(pid);
- if (exact is not null && exact.Count > 0)
- {
- system.AppendLine();
- system.AppendLine("## Exact memory (canonical KV defaults — prefer over RAG for numbers)");
- system.AppendLine("```json");
- system.AppendLine(exact.ToString(Newtonsoft.Json.Formatting.None));
- system.AppendLine("```");
- }
-
- foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
- {
- string skillText = Config.LoadSkillPrompt(pid, skillId);
- if (!string.IsNullOrWhiteSpace(skillText))
- {
- system.AppendLine();
- system.AppendLine($"## Skill: {skillId}");
- system.AppendLine(skillText);
- }
- }
-
- string identity = Config.RenderIdentityBlock(pid);
- if (!string.IsNullOrWhiteSpace(identity))
- {
- system.AppendLine();
- system.AppendLine(identity);
- }
-
- if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2" && packName != "core")
- {
- string situational = Config.LoadPackPrompt(pid, packName);
- if (!string.IsNullOrWhiteSpace(situational))
- {
- system.AppendLine();
- system.AppendLine($"## Active mode: {packName}");
- system.AppendLine(situational);
- }
- }
- if (!string.IsNullOrWhiteSpace(contextJson))
- {
- system.AppendLine();
- system.AppendLine("## Live SwarmUI context (JSON — trust this over guesses)");
- system.AppendLine("```json");
- system.AppendLine(contextJson);
- system.AppendLine("```");
- }
- if (!string.IsNullOrWhiteSpace(extraSystem))
- {
- system.AppendLine();
- system.AppendLine(extraSystem);
- }
- if (system.Length > 0)
- {
- ollamaMessages.Add(new JObject
- {
- ["role"] = "system",
- ["content"] = system.ToString(),
- });
- }
- foreach (JToken msg in userMessages ?? [])
- {
- if (msg is not JObject mo)
- {
- continue;
- }
- JObject copy = new()
- {
- ["role"] = mo["role"]?.ToString() ?? "user",
- ["content"] = mo["content"]?.ToString() ?? "",
- };
- if (mo["images"] is JArray images && images.Count > 0)
- {
- copy["images"] = images;
- }
- ollamaMessages.Add(copy);
- }
- return ollamaMessages;
- }
-
- string ResolvePersonaPrompt(string personaId) => Config.RenderIdentityBlock(personaId);
-
- static JObject TryParsePatch(string reply)
- {
- if (string.IsNullOrWhiteSpace(reply))
- {
- return null;
- }
- foreach (Match match in JsonFenceRe.Matches(reply))
- {
- string raw = match.Groups[1].Value.Trim();
- try
- {
- JObject obj = JObject.Parse(raw);
- if (obj is not null && (obj["prompt"] != null || obj["loras"] != null || obj["width"] != null
- || obj["height"] != null || obj["steps"] != null || obj["cfg"] != null
- || obj["seed"] != null || obj["sigma_shift"] != null || obj["sampler"] != null
- || obj["actions"] != null || obj["search_query"] != null || obj["civitai_query"] != null
- || obj["use_init_image"] != null || obj["clear_init_image"] != null
- || obj["init_creativity"] != null || obj["denoise"] != null
- || obj["use_mask_image"] != null || obj["clear_mask_image"] != null
- || obj["mask_blur"] != null || obj["mask_grow"] != null
- || obj["look_at"] != null || obj["vision_from"] != null || obj["vision_slots"] != null
- || obj["slot_to_init"] != null || obj["slot_to_mask"] != null
- || obj["snapshot_generate"] != null || obj["select_slot"] != null
- || obj["aspect"] != null || obj["images"] != null || obj["batch"] != null
- || obj["vary"] != null || obj["lock_seed"] != null
- || obj["creativity"] != null || obj["intensity"] != null
- || obj["complexity"] != null || obj["movement"] != null
- || obj["clear_prompt_images"] != null || obj["slot_to_prompt_image"] != null
- || obj["pack"] != null || obj["memories"] != null || obj["memory"] != null))
- {
- return obj;
- }
- }
- catch
- {
- // not json
- }
- }
- return null;
- }
-
- static string ExtractSearchQuery(JObject patch)
- {
- if (patch is null)
- {
- return null;
- }
- string q = (patch["search_query"] ?? patch["civitai_query"])?.ToString()?.Trim();
- if (!string.IsNullOrWhiteSpace(q))
- {
- return q;
- }
- if (patch["actions"] is JArray acts)
- {
- foreach (JToken a in acts)
- {
- if (string.Equals(a?.ToString(), "search_civitai", StringComparison.OrdinalIgnoreCase))
- {
- return q; // may still be null — caller checks
- }
- }
- }
- return null;
- }
-
- static bool WantsCivitaiSearch(JObject patch)
- {
- if (patch is null)
- {
- return false;
- }
- return !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch));
- }
-
- 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, out JArray skills)
- {
- JObject whole = raw ?? [];
- JObject nested = whole["raw"] as JObject;
- if (string.IsNullOrWhiteSpace(baseUrl))
- {
- baseUrl = whole["base_url"]?.ToString()
- ?? whole["baseUrl"]?.ToString()
- ?? nested?["base_url"]?.ToString()
- ?? nested?["baseUrl"]?.ToString();
- }
- if (string.IsNullOrWhiteSpace(model))
- {
- model = whole["model"]?.ToString() ?? nested?["model"]?.ToString();
- }
- if (string.IsNullOrWhiteSpace(pack))
- {
- pack = whole["pack"]?.ToString() ?? nested?["pack"]?.ToString();
- }
- if (whole["includeBase"] is not null)
- {
- includeBase = whole.Value("includeBase") ?? includeBase;
- }
- 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";
- skills = (whole["skills"] as JArray) ?? (nested?["skills"] as JArray);
- }
-
- async Task<(string reply, JObject raw, JArray civitaiResults)> RunChatWithHops(
- Session session,
- string root,
- string modelName,
- string packName,
- bool includeBase,
- string contextJson,
- JArray userMessages,
- Func onDelta = null,
- Func onHopStart = null,
- string personaId = null,
- JArray skillIds = null,
- string embedModel = null)
- {
- string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
- List skills = Config.ResolveEnabledSkills(pid, skillIds);
- string embed = string.IsNullOrWhiteSpace(embedModel)
- ? (Config.LoadSettings()["embed_model"]?.ToString()
- ?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
- ?? "nomic-embed-text")
- : embedModel;
-
- try
- {
- await Memory.EnsureSeedAsync(root, Config, embed);
- }
- catch (Exception ex)
- {
- Logs.Debug($"Assistent memory seed: {ex.Message}");
- }
-
- string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson);
- JArray hits = [];
- try
- {
- int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value() ?? 10;
- hits = await Memory.RetrieveAsync(root, retrieveQuery, topK, embed);
- }
- catch (Exception ex)
- {
- Logs.Debug($"Assistent memory retrieve: {ex.Message}");
- }
-
- string enrichedContext = InjectMemoryHits(contextJson, hits);
- List messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
- JArray civitaiResults = [];
- string reply = "";
- JObject lastRaw = null;
- int maxHops = CfgInt("max_civitai_hops", MaxCivitaiHopsFallback);
- for (int hop = 0; hop < maxHops; hop++)
- {
- if (onHopStart is not null)
- {
- await onHopStart(hop);
- }
- (reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
- JObject patch = TryParsePatch(reply);
- await ApplyMemoryActions(root, patch, embed);
- if (hop + 1 >= maxHops || !WantsCivitaiSearch(patch))
- {
- break;
- }
- string query = ExtractSearchQuery(patch);
- if (string.IsNullOrWhiteSpace(query))
- {
- break;
- }
- JObject search = await AssistentSearchCivitai(session, query, 8);
- if (search["error"] is not null)
- {
- messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
- messages.Add(new JObject
- {
- ["role"] = "user",
- ["content"] = $"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.",
- });
- continue;
- }
- civitaiResults = search["results"] as JArray ?? [];
- messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
- messages.Add(new JObject
- {
- ["role"] = "user",
- ["content"] =
- "Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
- "Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
- "Omit search_civitai from actions unless you need a different query.\n```json\n" +
- civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
- });
- }
- return (reply, lastRaw, civitaiResults);
- }
-
- static string BuildRetrieveQuery(JArray userMessages, string contextJson)
- {
- StringBuilder sb = new();
- if (!string.IsNullOrWhiteSpace(contextJson))
- {
- try
- {
- JObject ctx = JObject.Parse(contextJson);
- string ckpt = ctx["checkpoint"]?.ToString() ?? ctx["current_model"]?.ToString();
- if (!string.IsNullOrWhiteSpace(ckpt))
- {
- sb.Append(ckpt).Append(' ');
- }
- if (ctx["enabled_loras"] is JArray en)
- {
- foreach (JToken t in en.Take(8))
- {
- string n = t?["name"]?.ToString() ?? t?.ToString();
- if (!string.IsNullOrWhiteSpace(n))
- {
- sb.Append(n).Append(' ');
- }
- }
- }
- if (ctx["krea_profile"] != null)
- {
- sb.Append("krea ").Append(ctx["krea_profile"]).Append(' ');
- }
- }
- catch
- {
- // ignore
- }
- }
- foreach (JToken msg in (userMessages ?? []).Reverse().Take(2))
- {
- if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
- {
- sb.Append(mo["content"]?.ToString()).Append(' ');
- }
- }
- string q = CollapseWs(sb.ToString());
- return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q;
- }
-
- static string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
- {
- JObject ctx;
- try
- {
- ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson);
- }
- catch
- {
- ctx = new JObject { ["_raw_context"] = contextJson };
- }
- ctx["memory_hits"] = hits ?? new JArray();
- // Never re-inject full Exact into live context (already in system prompt).
- ctx.Remove("exact");
- if (ctx["session_exact"] is null)
- {
- ctx["session_exact"] = new JObject();
- }
- // Slim inventory for LLM: keep enabled + current, drop full dump if present
- if (ctx["available_loras"] is JArray allLoras && allLoras.Count > 24)
- {
- HashSet keep = new(StringComparer.OrdinalIgnoreCase);
- if (ctx["enabled_loras"] is JArray en)
- {
- foreach (JToken t in en)
- {
- string n = t?["name"]?.ToString() ?? t?.ToString();
- if (!string.IsNullOrWhiteSpace(n))
- {
- keep.Add(n);
- }
- }
- }
- foreach (JToken hit in hits ?? [])
- {
- if (string.Equals(hit?["kind"]?.ToString(), "lora", StringComparison.OrdinalIgnoreCase)
- || string.Equals(hit?["kind"]?.ToString(), "card", StringComparison.OrdinalIgnoreCase))
- {
- string k = hit?["key"]?.ToString();
- if (!string.IsNullOrWhiteSpace(k))
- {
- keep.Add(k);
- }
- }
- }
- JArray slim = [];
- foreach (JToken t in allLoras)
- {
- string n = t?["name"]?.ToString();
- if (!string.IsNullOrWhiteSpace(n) && (keep.Contains(n) || slim.Count < 12))
- {
- if (keep.Contains(n) || t?["krea_likely"]?.Value() == true)
- {
- slim.Add(t);
- }
- }
- }
- if (slim.Count == 0)
- {
- foreach (JToken t in allLoras.Take(12))
- {
- slim.Add(t);
- }
- }
- ctx["available_loras"] = slim;
- ctx["available_loras_truncated"] = true;
- ctx["available_loras_total"] = allLoras.Count;
- }
- return ctx.ToString(Newtonsoft.Json.Formatting.None);
- }
-
- async Task ApplyMemoryActions(string root, JObject patch, string embedModel)
- {
- if (patch is null || Memory is null)
- {
- return;
- }
- bool upsert = false, forget = false;
- if (patch["actions"] is JArray acts)
- {
- foreach (JToken a in acts)
- {
- string s = a?.ToString() ?? "";
- if (string.Equals(s, "memory_upsert", StringComparison.OrdinalIgnoreCase))
- {
- upsert = true;
- }
- if (string.Equals(s, "memory_forget", StringComparison.OrdinalIgnoreCase))
- {
- forget = true;
- }
- }
- }
- JArray memories = patch["memories"] as JArray;
- if (memories is null || memories.Count == 0)
- {
- return;
- }
- foreach (JToken t in memories)
- {
- if (t is not JObject mo)
- {
- continue;
- }
- string kind = mo["kind"]?.ToString() ?? "note";
- string key = mo["key"]?.ToString() ?? "";
- string text = mo["text"]?.ToString() ?? "";
- try
- {
- if (forget && string.IsNullOrWhiteSpace(text))
- {
- Memory.Forget(kind, key);
- }
- else if (upsert || !string.IsNullOrWhiteSpace(text))
- {
- await Memory.UpsertTextAsync(root, kind, key, text, "user", mo, embedModel);
- }
- }
- catch (Exception ex)
- {
- Logs.Debug($"ApplyMemoryActions: {ex.Message}");
- }
- }
- }
-
- async Task<(string reply, JObject raw)> CallOllamaChat(
- string root,
- string modelName,
- List ollamaMessages,
- bool stream,
- Func onDelta,
- string personaId = null)
- {
- int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value()
- ?? DefaultNumCtxFallback;
- JObject payload = new()
- {
- ["model"] = modelName,
- ["stream"] = stream,
- ["messages"] = new JArray(ollamaMessages),
- ["options"] = new JObject
- {
- ["num_ctx"] = numCtx,
- },
- ["keep_alive"] = "15m",
- };
- using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
- using HttpRequestMessage req = new(HttpMethod.Post, $"{root}/api/chat") { Content = content };
- using HttpResponseMessage resp = await HttpClient.SendAsync(req, stream
- ? HttpCompletionOption.ResponseHeadersRead
- : HttpCompletionOption.ResponseContentRead);
- if (!resp.IsSuccessStatusCode)
- {
- string errBody = await resp.Content.ReadAsStringAsync();
- throw new Exception($"Ollama /api/chat HTTP {(int)resp.StatusCode}: {Clip(errBody, 800)}");
- }
- if (!stream)
- {
- string body = await resp.Content.ReadAsStringAsync();
- JObject parsed = JObject.Parse(body);
- string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? "";
- return (reply, parsed);
- }
- StringBuilder full = new();
- await using Stream streamBody = await resp.Content.ReadAsStreamAsync();
- using StreamReader reader = new(streamBody, Encoding.UTF8);
- JObject last = null;
- while (true)
- {
- string line = await reader.ReadLineAsync();
- if (line is null)
- {
- break;
- }
- if (string.IsNullOrWhiteSpace(line))
- {
- continue;
- }
- JObject chunk = JObject.Parse(line);
- last = chunk;
- string delta = chunk["message"]?["content"]?.ToString() ?? "";
- if (!string.IsNullOrEmpty(delta))
- {
- full.Append(delta);
- if (onDelta is not null)
- {
- await onDelta(delta);
- }
- }
- if (chunk["done"]?.Value() == true)
- {
- break;
- }
- }
- return (full.ToString(), last ?? new JObject());
- }
-
- ///
- /// SwarmUI passes the whole request as the JObject param (not only a nested key).
- /// Support both flat fields and legacy nested raw.
- ///
- // ExtractChatPayload defined above
-
- /// 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, out string persona, out JArray skills);
- string root = NormalizeBaseUrl(baseUrl);
- string modelName = (model ?? "").Trim();
- if (string.IsNullOrWhiteSpace(modelName))
- {
- return new JObject { ["error"] = "model is required" };
- }
- if (userMessages is null || userMessages.Count == 0)
- {
- return new JObject { ["error"] = "messages required" };
- }
- string packName = (pack ?? "write_prompt").Trim();
- string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
- try
- {
- (string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
- session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
- return new JObject
- {
- ["success"] = true,
- ["reply"] = reply,
- ["model"] = modelName,
- ["pack"] = packName,
- ["persona"] = persona,
- ["raw"] = parsed,
- ["civitai_results"] = civitai,
- };
- }
- catch (Exception ex)
- {
- return new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" };
- }
- }
-
- /// 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, out string persona, out JArray skills);
- string root = NormalizeBaseUrl(baseUrl);
- string modelName = (model ?? "").Trim();
- if (string.IsNullOrWhiteSpace(modelName))
- {
- await ws.SendJson(new JObject { ["error"] = "model is required" }, API.WebsocketTimeout);
- return null;
- }
- if (userMessages is null || userMessages.Count == 0)
- {
- await ws.SendJson(new JObject { ["error"] = "messages required" }, API.WebsocketTimeout);
- return null;
- }
- string packName = (pack ?? "write_prompt").Trim();
- string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
- try
- {
- if (ws.State == WebSocketState.Open)
- {
- await ws.SendJson(new JObject
- {
- ["phase"] = "waiting_ollama",
- ["notice"] = "Loading model into GPU…",
- }, API.WebsocketTimeout);
- }
- async Task OnDelta(string delta)
- {
- if (ws.State == WebSocketState.Open)
- {
- await ws.SendJson(new JObject { ["delta"] = delta }, API.WebsocketTimeout);
- }
- }
- async Task OnHopStart(int hop)
- {
- if (ws.State == WebSocketState.Open && hop > 0)
- {
- await ws.SendJson(new JObject
- {
- ["clear_stream"] = true,
- ["hop"] = hop + 1,
- ["notice"] = "Civitai search done — refining…",
- }, API.WebsocketTimeout);
- }
- }
- (string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
- session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
- await ws.SendJson(new JObject
- {
- ["success"] = true,
- ["done"] = true,
- ["reply"] = reply,
- ["model"] = modelName,
- ["pack"] = packName,
- ["persona"] = persona,
- ["raw"] = parsed,
- ["civitai_results"] = civitai,
- }, API.WebsocketTimeout);
- }
- catch (Exception ex)
- {
- await ws.SendJson(new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" }, API.WebsocketTimeout);
- }
- return null;
- }
}
diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html
index 54e6841..33b439f 100644
--- a/Tabs/Text2Image/Assistent.html
+++ b/Tabs/Text2Image/Assistent.html
@@ -18,6 +18,7 @@
—
+
@@ -36,6 +37,7 @@
Assistent
+
Ollama · …
@@ -76,6 +78,18 @@
Скилы (процедуры)
+
+ Память
+
+
+
+
+
@@ -96,20 +110,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+