Ship Assistent 0.8.1: split modules and shared+personal vector memory.

Personal RAG never leaks into the shared store; retrieve merges shared plus the persona chain, with personal overwrite on kind+key.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 00:38:54 +03:00
co-authored by Cursor
parent 6d8aaefc38
commit 880e2dbea2
21 changed files with 3946 additions and 2040 deletions
+27
View File
@@ -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'))),
);
});
};
+126 -73
View File
@@ -84,16 +84,6 @@
opacity: 0.9; 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 { .sa-board {
flex: 1; flex: 1;
display: grid; display: grid;
@@ -247,43 +237,6 @@
opacity: 0.9; 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 { .sa-image-empty {
padding: 1.25rem 1rem; padding: 1.25rem 1rem;
text-align: center; text-align: center;
@@ -302,32 +255,6 @@
opacity: 0.9; 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 { .sa-image-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -786,6 +713,132 @@
margin-bottom: 0.25rem; 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 { .sa-settings .sa-select {
width: 100%; width: 100%;
} }
+597 -88
View File
@@ -1,6 +1,6 @@
/** /**
* Swarm Assistent Krea 2 collaborative chat (Ollama via Swarm API). * 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 () { (function () {
const LS_BASE = 'swarm_assistent_base_url'; const LS_BASE = 'swarm_assistent_base_url';
@@ -24,8 +24,9 @@
const MAX_CHAT_MSGS = 24; const MAX_CHAT_MSGS = 24;
const TAB_BUTTON_ID = 'maintab_assistent'; const TAB_BUTTON_ID = 'maintab_assistent';
const GEN_ID = 'generate'; const GEN_ID = 'generate';
const MAX_REF_SLOTS = 4; let MAX_REF_SLOTS = 4;
const CONTEXT_PROMPT_MAX = 2000; let CONTEXT_PROMPT_MAX = 2000;
let HISTORY_KEEP_TURNS = 4;
let ASPECT_TABLE = { let ASPECT_TABLE = {
'1:1': [1024, 1024], '1:1': [1024, 1024],
@@ -155,8 +156,18 @@
restoringChat: false, restoringChat: false,
chatsPanelOpen: false, chatsPanelOpen: false,
slashIndex: 0, 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) { function $(id) {
return document.getElementById(id); return document.getElementById(id);
} }
@@ -215,6 +226,7 @@
thinking: 'Thinking…', thinking: 'Thinking…',
streaming: 'Writing…', streaming: 'Writing…',
generating: 'Generating image…', generating: 'Generating image…',
parking: 'Освобождаю VRAM (park LLM)…',
applying: 'Applying patch…', applying: 'Applying patch…',
silent_gen: 'Применяю патч → Generate…', silent_gen: 'Применяю патч → Generate…',
refining: 'Civitai search done — refining…', refining: 'Civitai search done — refining…',
@@ -676,6 +688,7 @@
const tab = document.getElementById(TAB_BUTTON_ID); const tab = document.getElementById(TAB_BUTTON_ID);
if (tab) { if (tab) {
tab.click(); tab.click();
setTimeout(() => $('sa_input')?.focus(), 50);
return true; return true;
} }
const pane = document.getElementById('assistent'); const pane = document.getElementById('assistent');
@@ -684,9 +697,15 @@
bootstrap.Tab.getOrCreateInstance(tab || pane).show(); bootstrap.Tab.getOrCreateInstance(tab || pane).show();
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
} }
setTimeout(() => $('sa_input')?.focus(), 50);
return !!tab; return !!tab;
} }
function historyMessageLimit() {
const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
return turns * 2;
}
function flashImagePane(slotId) { function flashImagePane(slotId) {
const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`); const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
if (!el) { if (!el) {
@@ -1540,6 +1559,7 @@
})); }));
state.chats = chats; state.chats = chats;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats })); localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
saveActiveChatToDisk();
} catch (e) { } catch (e) {
console.warn('Assistent: persist chats failed', e); console.warn('Assistent: persist chats failed', e);
try { try {
@@ -1550,19 +1570,33 @@
.slice(0, 12) .slice(0, 12)
.map((c) => ({ .map((c) => ({
...c, ...c,
messages: slimHistoryMessages(c.messages).slice(-12).map((m) => ({ messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
...m, ...m,
content: String(m.content || '').slice(0, 1500), content: String(m.content || '').slice(0, 1500),
})), })),
})); }));
state.chats = slim; state.chats = slim;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim })); localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
saveActiveChatToDisk();
} catch (e2) { } catch (e2) {
console.warn('Assistent: chats quota fallback failed', 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() { function loadChatsStore() {
state.chats = []; state.chats = [];
try { try {
@@ -1577,6 +1611,28 @@
migrateLegacyHistoryIntoChats(); 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() { function migrateLegacyHistoryIntoChats() {
try { try {
const raw = localStorage.getItem(LS_HISTORY); const raw = localStorage.getItem(LS_HISTORY);
@@ -1862,6 +1918,7 @@
if (wasActive) { if (wasActive) {
state.activeChatId = null; state.activeChatId = null;
} }
diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
persistChatsStore(); persistChatsStore();
syncHistoryBadge(); syncHistoryBadge();
if (wasActive) { if (wasActive) {
@@ -1871,11 +1928,13 @@
} }
} }
function initChatSessions() { async function initChatSessions() {
loadChatsStore(); loadChatsStore();
await loadChatsFromDisk();
// Always open a fresh chat on startup; past chats stay in History. // Always open a fresh chat on startup; past chats stay in History.
startNewChat({ saveCurrent: false, force: true }); startNewChat({ saveCurrent: false, force: true });
syncHistoryBadge(); syncHistoryBadge();
renderChatsList();
} }
function persistHistory() { function persistHistory() {
@@ -2588,51 +2647,21 @@
saveTaste(); 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) { function isPatchObject(obj) {
if (window.SA && typeof SA.isPatchObject === 'function') {
return SA.isPatchObject(obj);
}
if (!obj || typeof obj !== 'object') { if (!obj || typeof obj !== 'object') {
return false; return false;
} }
return ( return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
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
);
} }
function isCardObject(obj) { function isCardObject(obj) {
@@ -2676,6 +2705,9 @@
} }
function extractPatch(text) { function extractPatch(text) {
if (window.SA && typeof SA.extractPatch === 'function') {
return SA.extractPatch(text);
}
if (!text) { if (!text) {
return { prose: text || '', patch: null }; return { prose: text || '', patch: null };
} }
@@ -2684,9 +2716,8 @@
let lastPatch = null; let lastPatch = null;
let prose = text; let prose = text;
while ((match = re.exec(text)) !== null) { while ((match = re.exec(text)) !== null) {
const raw = match[1].trim();
try { try {
const obj = JSON.parse(raw); const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) { if (isPatchObject(obj)) {
lastPatch = obj; lastPatch = obj;
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim(); prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
@@ -3169,6 +3200,42 @@
return false; 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() { function cancelWaitForNewImage() {
if (state.waitImageTimer) { if (state.waitImageTimer) {
clearInterval(state.waitImageTimer); clearInterval(state.waitImageTimer);
@@ -3259,6 +3326,9 @@
return null; return null;
} }
const prev = findCurrentGenerateSrc(); const prev = findCurrentGenerateSrc();
startBusyUi('parking');
setStatus('Освобождаю VRAM…');
await parkLlm();
setStatus('Генерация…'); setStatus('Генерация…');
startBusyUi('generating'); startBusyUi('generating');
state.generating = true; state.generating = true;
@@ -3276,6 +3346,9 @@
if (!state.busy) { if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)'); stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
} }
if (state.view === 'chat') {
warmLlm();
}
if (src) { if (src) {
const gen = generateSlot(); const gen = generateSlot();
if (gen) { if (gen) {
@@ -3291,31 +3364,77 @@
return null; 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) { 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; return;
} }
state.critiqueHopUsed = true; state.critiqueHopUsed = true;
const pack = $('sa_pack'); setPackValue('critique_image', { flash: true });
if (pack) {
pack.value = 'critique_image';
saveSettings();
}
if ($('sa_input')) { if ($('sa_input')) {
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.'; $('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
} }
const gen = generateSlot(); const gen = generateSlot();
if (gen) { if (gen) {
gen.attach = true; gen.attach = true;
if (imageSrc) { gen.src = src;
gen.src = imageSrc;
}
renderBoard(); renderBoard();
} }
setStatus('Auto-critique…'); setStatus('Auto-critique…');
await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] }); 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() { function currentPersonaInfo() {
const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral'; const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
const known = (state.personas || []).find((p) => p && p.id === id); const known = (state.personas || []).find((p) => p && p.id === id);
@@ -3505,18 +3624,18 @@
actions.className = 'sa-civitai-actions'; actions.className = 'sa-civitai-actions';
if (r.already_installed) { if (r.already_installed) {
const note = document.createElement('span'); const note = document.createElement('span');
note.textContent = 'Installed'; note.textContent = 'Уже установлена';
actions.appendChild(note); actions.appendChild(note);
} else if (r.download_url) { } else if (r.download_url) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.className = 'basic-button sa-primary'; btn.className = 'basic-button sa-primary';
btn.textContent = 'Confirm download'; btn.textContent = 'Подтвердить скачивание';
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn)); btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
actions.appendChild(btn); actions.appendChild(btn);
} else { } else {
const note = document.createElement('span'); const note = document.createElement('span');
note.textContent = 'No download URL'; note.textContent = 'Нет URL скачивания';
actions.appendChild(note); actions.appendChild(note);
} }
card.appendChild(actions); card.appendChild(actions);
@@ -3531,9 +3650,9 @@
} }
if (btn) { if (btn) {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Downloading…'; btn.textContent = 'Скачиваю…';
} }
setStatus(`Downloading ${card.file_name || card.name}`); setStatus(`Скачиваю ${card.file_name || card.name}`);
setInterruptVisible(true); setInterruptVisible(true);
const payload = { const payload = {
url: card.download_url, url: card.download_url,
@@ -3543,9 +3662,9 @@
const onDone = (ok, msg) => { const onDone = (ok, msg) => {
setInterruptVisible(state.busy || state.generating); setInterruptVisible(state.busy || state.generating);
if (ok) { if (ok) {
setStatus(`Downloaded ${payload.name}`); setStatus(`Скачано ${payload.name}`);
if (btn) { if (btn) {
btn.textContent = 'Downloaded'; btn.textContent = 'Скачано';
} }
refreshInventory(async () => { refreshInventory(async () => {
await maybeWriteCardAfterDownload({ await maybeWriteCardAfterDownload({
@@ -3555,12 +3674,12 @@
}); });
}, { rescan: true }); }, { rescan: true });
} else { } else {
setStatus(msg || 'Download failed'); setStatus(msg || 'Ошибка скачивания');
if (btn) { if (btn) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Confirm download'; btn.textContent = 'Подтвердить скачивание';
} }
appendMessage('error', msg || 'Download failed'); appendMessage('error', msg || 'Ошибка скачивания');
} }
}; };
if (typeof makeWSRequest === 'function') { 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() { function saveSettings() {
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || ''); localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
localStorage.setItem(LS_MODEL, $('sa_model')?.value || ''); localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
@@ -3871,6 +4072,7 @@
localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
persistServerSettings(); persistServerSettings();
saveUiStateToDisk();
} }
function persistServerSettings() { function persistServerSettings() {
@@ -3936,6 +4138,16 @@
if (data.assistant?.embed_model && !state.preferredEmbed) { if (data.assistant?.embed_model && !state.preferredEmbed) {
state.preferredEmbed = data.assistant.embed_model; 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) { if (applyDefaults || data.exact) {
fillEmptyParamsFromExact(); fillEmptyParamsFromExact();
} }
@@ -4147,6 +4359,11 @@
$('sa_model').value = prefer; $('sa_model').value = prefer;
} }
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)'); 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(); saveSettings();
}, },
0, 0,
@@ -4154,6 +4371,7 @@
const msg = String(err || 'Ollama unreachable'); const msg = String(err || 'Ollama unreachable');
setStatus(msg); setStatus(msg);
setModelOptions([], { error: msg }); setModelOptions([], { error: msg });
setOllamaHealth('down', 'Ollama ✕', msg);
appendMessage('error', msg); appendMessage('error', msg);
}, },
); );
@@ -4205,6 +4423,224 @@
return new Promise((resolve) => refreshInventory(resolve, opts)); 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 = '<div class="sa-mem-empty">Память пуста — она наполняется из карточек, seed и патчей <code>memory_upsert</code>.</div>';
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 = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-kind-badge">${escapeHtml(row.kind || 'note')}</span><span class="sa-mem-row-key">${escapeHtml(row.key || '')}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 220))}</div><div class="sa-mem-row-meta">${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}</div></div>`;
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 = '<div class="sa-mem-empty">Читаю память…</div>';
}
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 = `<div class="sa-mem-empty">Память недоступна: ${escapeHtml(String(err || 'ошибка'))}</div>`;
}
},
);
}
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) { function setCardStatus(msg) {
const el = $('sa_card_status'); const el = $('sa_card_status');
if (el) { if (el) {
@@ -4227,6 +4663,9 @@
saveSettings(); saveSettings();
if (state.view === 'cards') { if (state.view === 'cards') {
renderCardsList(); 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) { if (row.has_sidecar) {
metaBits.push('sidecar'); metaBits.push('sidecar');
} }
if (isWantedModel(row)) {
metaBits.push('⏳ wanted');
btn.classList.add('sa-card-row-wanted');
}
if (row.trigger) { if (row.trigger) {
metaBits.push(String(row.trigger).slice(0, 40)); metaBits.push(String(row.trigger).slice(0, 40));
} }
@@ -4863,7 +5306,10 @@
} }
const remoteUpdated = remote.updated || 0; const remoteUpdated = remote.updated || 0;
const localUpdated = state.taste?.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 = { state.taste = {
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [], styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [], likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
@@ -5157,7 +5603,7 @@
} else { } else {
why.push('последнего патча Assistent ещё нет'); 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 = [ const lines = [
'### Debug Assistent', '### Debug Assistent',
@@ -5481,6 +5927,8 @@
const chatEpoch = bumpChatEpoch(); const chatEpoch = bumpChatEpoch();
state.busy = true; state.busy = true;
// Ollama reloads the model for this request (keep_alive 15m), so it is no longer parked.
state.llmParked = false;
setInterruptVisible(true); setInterruptVisible(true);
startBusyUi('thinking'); startBusyUi('thinking');
saveSettings(); saveSettings();
@@ -5565,7 +6013,7 @@
// Refresh cards into context after prefetch // Refresh cards into context after prefetch
const refreshed = collectLiveContext(); const refreshed = collectLiveContext();
context.model_cards = refreshed.model_cards; 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) { if (images && messages.length) {
messages[messages.length - 1].images = images; messages[messages.length - 1].images = images;
} }
@@ -5584,16 +6032,6 @@
context_json: JSON.stringify(context), context_json: JSON.stringify(context),
skills: state.enabledSkills || [], skills: state.enabledSkills || [],
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '', 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) => { 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() { function wire() {
if (!$('swarm_assistent_root')) { if (!$('swarm_assistent_root')) {
return; return;
@@ -5893,14 +6354,7 @@
if (wantsAutoVision()) { if (wantsAutoVision()) {
refreshImagePreview(); refreshImagePreview();
} }
initChatSessions(); bootstrapPersisted();
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
refreshModels();
refreshInventory(() => {
renderCardsList();
renderLoraChips();
});
});
wireDropZone(); wireDropZone();
wireSplitter(); wireSplitter();
registerSendButton(); registerSendButton();
@@ -5957,11 +6411,51 @@
const s = $('sa_settings'); const s = $('sa_settings');
if (s) { if (s) {
s.hidden = !s.hidden; 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', () => { $('sa_btn_refresh_models')?.addEventListener('click', () => {
saveSettings(); saveSettings();
refreshModels(); refreshModels();
probeOllamaHealth();
}); });
$('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => { $('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
renderCardsList(); renderCardsList();
@@ -6104,9 +6598,24 @@
setInterval(updateGate, 2000); setInterval(updateGate, 2000);
setInterval(syncGenerateSlot, 700); setInterval(syncGenerateSlot, 700);
setInterval(() => {
if (!state.busy && !state.generating) {
probeOllamaHealth();
}
}, 45000);
setInterval(() => {
if (!state.busy && !state.generating) {
refreshWantedQueue();
}
}, 120000);
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
try { try {
saveActiveChatToStore({ dropEmpty: true }); 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 */ } } catch (e) { /* ignore */ }
}); });
setInterval(() => { setInterval(() => {
+79
View File
@@ -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;
})();
+191
View File
@@ -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,
};
})();
+385
View File
@@ -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;
/// <summary>Prompt assembly, memory retrieval/writeback and the Civitai search hop loop.</summary>
public partial class SwarmAssistentExtension
{
const int MaxCivitaiHopsFallback = 2;
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
{
List<JObject> 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<string, Task> onDelta = null,
Func<int, Task> onHopStart = null,
string personaId = null,
JArray skillIds = null,
string embedModel = null)
{
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
List<string> 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<int?>() ?? 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<JObject> 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<string> 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<bool>() == 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}");
}
}
}
}
+50 -19
View File
@@ -189,7 +189,8 @@ public sealed class AssistentConfig
return last; return last;
} }
List<string> PersonaExtendsChain(string personaId) /// <summary>Ancestor-first chain ending with the current persona (for overlay merge and vector retrieve).</summary>
public List<string> PersonaExtendsChain(string personaId)
{ {
List<string> chain = []; List<string> chain = [];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase); HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
@@ -498,12 +499,19 @@ public sealed class AssistentConfig
JObject rules = MergeJsonLayers("rules.json", roots); JObject rules = MergeJsonLayers("rules.json", roots);
string extra = MergeTextLayers("extra.md", roots); string extra = MergeTextLayers("extra.md", roots);
// Legacy personas.json prompt → extra overlay // Legacy personas.json: only when no overlay persona folder exists for this id
// (gpu-rent now seeds personas/<id>/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 overlayJson = Path.Combine(_overlayRoot, "personas.json"); string overlayJson = Path.Combine(_overlayRoot, "personas.json");
JObject legacy = TryReadJson(overlayJson); JObject legacy = TryReadJson(overlayJson);
if (legacy?["personas"] is JArray arr) if (legacy?["personas"] is JArray arr)
{ {
string id = SafeId(personaId) ?? "neutral";
foreach (JToken t in arr) foreach (JToken t in arr)
{ {
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase)) if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
@@ -522,6 +530,7 @@ public sealed class AssistentConfig
} }
} }
} }
}
return new JObject return new JObject
{ {
@@ -641,35 +650,51 @@ public sealed class AssistentConfig
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
static IEnumerable<string> 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;
}
}
}
/// <summary>Seed docs: shared from _base/memory-seed, personal from personas/&lt;id&gt;/memory-seed. Later files overwrite earlier same kind+key in the list; persona is stamped on each doc.</summary>
public List<JObject> LoadMemorySeedDocs() public List<JObject> LoadMemorySeedDocs()
{ {
List<JObject> docs = []; List<JObject> docs = [];
void Scan(string root) void Scan(string dir, string persona)
{ {
string dir = Path.Combine(root, "memory-seed"); if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
if (!Directory.Exists(dir))
{ {
return; return;
} }
string stamp = AssistentMemory.NormalizePersona(persona);
foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase)) foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
{ {
try try
{ {
string raw = File.ReadAllText(file, Encoding.UTF8); string raw = File.ReadAllText(file, Encoding.UTF8);
JToken parsed = JToken.Parse(raw); JToken parsed = JToken.Parse(raw);
if (parsed is JArray arr) IEnumerable<JObject> items = parsed is JArray arr
? arr.OfType<JObject>()
: 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) clone["persona"] = stamp;
{
docs.Add(jo);
} }
} docs.Add(clone);
}
else if (parsed is JObject single)
{
docs.Add(single);
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -678,9 +703,15 @@ public sealed class AssistentConfig
} }
} }
} }
Scan(Path.Combine(_bundledRoot, "_base"));
Scan(Path.Combine(_overlayRoot, "_base")); Scan(Path.Combine(_bundledRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
Scan(Path.Combine(_overlayRoot, "memory-seed")); 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; return docs;
} }
+829
View File
@@ -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;
/// <summary>Server-side model inventory, assistant cards and Civitai lookups.</summary>
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<JObject> AssistentGetCard(Session session, string kind, string name)
{
await Task.CompletedTask;
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
{
return new JObject { ["error"] = "kind and name required" };
}
JObject card = ReadCardObject(kind, name);
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
return new JObject
{
["success"] = true,
["kind"] = kind,
["name"] = name,
["has_card"] = card is not null,
["weight_path"] = weight,
["card"] = card,
};
}
public async Task<JObject> AssistentSaveCard(Session session, string kind, string name, JObject card, bool enqueue_wanted = false)
{
await Task.CompletedTask;
if (card is null)
{
return new JObject { ["error"] = "card required" };
}
kind = (kind ?? card["kind"]?.ToString() ?? "").Trim();
name = (name ?? card["name"]?.ToString() ?? "").Trim();
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
{
return new JObject { ["error"] = "kind and name required" };
}
card["kind"] = kind;
card["name"] = name;
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
if (!string.IsNullOrWhiteSpace(weight) && File.Exists(weight))
{
string path = CardPathForWeight(weight);
File.WriteAllText(path, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
_ = 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<int?>() ?? 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<string> 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<JObject> 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<int?>() ?? 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<int?>() ?? 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<JObject> 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<JObject> 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;
}
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).</summary>
public async Task<JObject> AssistentListInventory(Session session, bool rescan = false)
{
await Task.CompletedTask;
if (rescan)
{
try
{
Program.RefreshAllModelSets();
}
catch (Exception ex)
{
Logs.Debug($"AssistentListInventory rescan: {ex.Message}");
try
{
Program.ModelRefreshEvent?.Invoke();
}
catch (Exception ex2)
{
Logs.Debug($"AssistentListInventory ModelRefreshEvent: {ex2.Message}");
}
}
}
JArray loras = [];
JArray 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;
}
/// <summary>Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key.</summary>
public async Task<JObject> AssistentSearchCivitai(Session session, string query, int limit = 8)
{
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<string> installedNames = CollectInstalledLoraNames();
HashSet<string> 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<string> CollectInstalledLoraNames()
{
HashSet<string> 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<string> CollectInstalledLoraHashes()
{
HashSet<string> 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<string> installedNames, HashSet<string> 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<string> 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<bool>() == 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<bool>() ?? false,
};
}
}
+257 -46
View File
@@ -11,9 +11,13 @@ using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent; namespace Mrleo1nid.SwarmAssistent;
/// <summary>Local SQLite vector memory with Ollama /api/embed.</summary> /// <summary>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.</summary>
public sealed class AssistentMemory : IDisposable public sealed class AssistentMemory : IDisposable
{ {
public const string SharedPersona = "";
readonly string _dbPath; readonly string _dbPath;
readonly HttpClient _http; readonly HttpClient _http;
readonly object _lock = new(); readonly object _lock = new();
@@ -35,6 +39,22 @@ public sealed class AssistentMemory : IDisposable
public int Dims => _dims; public int Dims => _dims;
public int SeedVersion => _seedVersion; 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() void EnsureOpen()
{ {
if (_conn is not null) if (_conn is not null)
@@ -55,22 +75,85 @@ public sealed class AssistentMemory : IDisposable
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL, kind TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
persona TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL, text TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'user', source TEXT NOT NULL DEFAULT 'user',
meta_json TEXT, meta_json TEXT,
embedding BLOB, embedding BLOB,
updated INTEGER NOT NULL, 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(); 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; _embedModel = GetMeta("embed_model") ?? _embedModel;
_ = int.TryParse(GetMeta("dims"), out _dims); _ = int.TryParse(GetMeta("dims"), out _dims);
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion); _ = 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) string GetMeta(string key)
{ {
using SqliteCommand cmd = _conn.CreateCommand(); using SqliteCommand cmd = _conn.CreateCommand();
@@ -126,6 +209,11 @@ public sealed class AssistentMemory : IDisposable
return (float)(dot / (Math.Sqrt(na) * Math.Sqrt(nb))); 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<float[]> EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m") public async Task<float[]> EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m")
{ {
string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/'); string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/');
@@ -154,6 +242,16 @@ public sealed class AssistentMemory : IDisposable
return floats; 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) public async Task EnsureSeedAsync(string baseUrl, AssistentConfig config, string modelOverride = null)
{ {
lock (_lock) lock (_lock)
@@ -167,28 +265,32 @@ public sealed class AssistentMemory : IDisposable
: modelOverride.Trim(); : modelOverride.Trim();
bool needReseed = _seedVersion != wantVersion || !string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase); 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<JObject> docs = config.LoadMemorySeedDocs(); List<JObject> docs = config.LoadMemorySeedDocs();
if (docs.Count == 0) if (docs.Count == 0)
{ {
return; 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; float[] probe;
try try
{ {
@@ -228,14 +330,26 @@ public sealed class AssistentMemory : IDisposable
string kind = (doc["kind"]?.ToString() ?? "note").Trim(); string kind = (doc["kind"]?.ToString() ?? "note").Trim();
string key = (doc["key"]?.ToString() ?? "").Trim(); string key = (doc["key"]?.ToString() ?? "").Trim();
string text = (doc["text"]?.ToString() ?? "").Trim(); string text = (doc["text"]?.ToString() ?? "").Trim();
string persona = NormalizePersona(doc["persona"]?.ToString());
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
{ {
continue; continue;
} }
if (!needReseed)
{
lock (_lock)
{
EnsureOpen();
if (BundledExists(kind.ToLowerInvariant(), key, persona))
{
continue;
}
}
}
try try
{ {
float[] vec = await EmbedAsync(baseUrl, wantModel, text); 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) 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) lock (_lock)
{ {
@@ -253,6 +367,7 @@ public sealed class AssistentMemory : IDisposable
key = (key ?? "").Trim(); key = (key ?? "").Trim();
text = (text ?? "").Trim(); text = (text ?? "").Trim();
source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim(); source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim();
persona = NormalizePersona(persona);
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text)) if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
{ {
return; return;
@@ -268,9 +383,9 @@ public sealed class AssistentMemory : IDisposable
using SqliteCommand cmd = _conn.CreateCommand(); using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = cmd.CommandText =
""" """
INSERT INTO memories(kind, key, text, source, meta_json, embedding, updated) INSERT INTO memories(kind, key, persona, text, source, meta_json, embedding, updated)
VALUES($kind, $key, $text, $source, $meta, $emb, $upd) VALUES($kind, $key, $persona, $text, $source, $meta, $emb, $upd)
ON CONFLICT(kind, key, source) DO UPDATE SET ON CONFLICT(kind, key, source, persona) DO UPDATE SET
text = excluded.text, text = excluded.text,
meta_json = excluded.meta_json, meta_json = excluded.meta_json,
embedding = excluded.embedding, embedding = excluded.embedding,
@@ -278,6 +393,7 @@ public sealed class AssistentMemory : IDisposable
"""; """;
cmd.Parameters.AddWithValue("$kind", kind); cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$key", key); cmd.Parameters.AddWithValue("$key", key);
cmd.Parameters.AddWithValue("$persona", persona);
cmd.Parameters.AddWithValue("$text", text); cmd.Parameters.AddWithValue("$text", text);
cmd.Parameters.AddWithValue("$source", source); cmd.Parameters.AddWithValue("$source", source);
cmd.Parameters.AddWithValue("$meta", meta?.ToString(Newtonsoft.Json.Formatting.None) ?? ""); 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) /// <summary>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.</summary>
public void Forget(string kind, string key, string source = null, string persona = null)
{ {
lock (_lock) lock (_lock)
{ {
EnsureOpen(); EnsureOpen();
persona = NormalizePersona(persona);
using SqliteCommand cmd = _conn.CreateCommand(); using SqliteCommand cmd = _conn.CreateCommand();
if (string.IsNullOrWhiteSpace(source)) 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 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("$source", source);
} }
cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant()); cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant());
cmd.Parameters.AddWithValue("$key", (key ?? "").Trim()); cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
cmd.Parameters.AddWithValue("$persona", persona);
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null) /// <summary>Retrieve shared + the given persona chain. Personal overwrites shared (and parent personas) on kind+key.</summary>
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable<string> personaChain = null)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
{ {
@@ -330,53 +451,143 @@ public sealed class AssistentMemory : IDisposable
return []; return [];
} }
List<(float score, JObject row)> scored = []; Dictionary<string, int> 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) lock (_lock)
{ {
EnsureOpen(); EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand(); 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(); using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read()) 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[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
float score = Cosine(q, emb); float score = Cosine(q, emb);
if (float.IsNegativeInfinity(score)) if (float.IsNegativeInfinity(score))
{ {
continue; 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<string, (float score, int personaRank, int sourceRank, JObject row)> 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<JObject> 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), ["kind"] = reader.GetString(0),
["key"] = reader.GetString(1), ["key"] = reader.GetString(1),
["text"] = reader.GetString(2), ["text"] = reader.GetString(2),
["source"] = reader.GetString(3), ["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) lock (_lock)
{ {
EnsureOpen(); EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand(); 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(); using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read()) 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) if (rows.Count == 0)
@@ -408,7 +619,7 @@ public sealed class AssistentMemory : IDisposable
{ {
try { meta = JToken.Parse(row.meta); } catch { /* ignore */ } 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) catch (Exception ex)
{ {
+215
View File
@@ -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;
/// <summary>Read/write routes for the vector memory list in ⚙ and the gpu-rent wanted queue badge.</summary>
public partial class SwarmAssistentExtension
{
/// <summary>Embed model the UI should use: settings overlay wins, then persona assistant.json.</summary>
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<JObject> 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<JToken> 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<JObject> 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<JObject> 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}" };
}
}
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
public async Task<JObject> 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<string, List<WantedEntry>> sections = LoadWantedYaml(File.ReadAllText(path, Encoding.UTF8));
foreach ((string kind, List<WantedEntry> 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}" };
}
}
}
+328
View File
@@ -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;
/// <summary>Ollama transport: model listing, /api/chat calls and the two chat API endpoints.</summary>
public partial class SwarmAssistentExtension
{
const int DefaultNumCtxFallback = 16384;
public async Task<JObject> 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<string> chatSet = new(
(roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
StringComparer.OrdinalIgnoreCase);
HashSet<string> 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<JObject> ollamaMessages,
bool stream,
Func<string, Task> onDelta,
string personaId = null)
{
int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value<int?>()
?? 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<bool>() == true)
{
break;
}
}
return (full.ToString(), last ?? new JObject());
}
/// <summary>SwarmUI hands the whole request body over as the JObject param, so every field is read flat off it.</summary>
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<bool?>("includeBase") ?? includeBase;
}
userMessages = whole["messages"] as JArray;
contextJson = whole["context_json"]?.ToString();
persona = whole["persona"]?.ToString() ?? "neutral";
skills = whole["skills"] as JArray;
}
/// <summary>Shared validation for both chat endpoints. Returns an error message, or null when the request is usable.</summary>
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;
}
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
{
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}" };
}
}
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
{
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;
}
}
+98
View File
@@ -0,0 +1,98 @@
using System;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Parsing and normalization of the JSON patch the model emits inside fenced code blocks.</summary>
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;
}
/// <summary>Maps legacy/alias patch fields onto their canonical names. Aliases are kept so older consumers still work.</summary>
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));
}
}
+320
View File
@@ -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;
/// <summary>Disk persistence for chat sessions and UI state under <c>DataRoot()/Assistent/</c>.
/// Chats survive browser storage wipes and follow the data volume across gpu-rent VMs.</summary>
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);
/// <summary>UI-state keys accepted from the browser — anything else is dropped.</summary>
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<JObject> LoadAllChats()
{
string dir = AssistentChatsDir();
if (!Directory.Exists(dir))
{
return [];
}
List<JObject> 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<long?>() ?? 0)
.ToList();
}
/// <summary>All chats on disk, newest first. Pass <c>with_messages</c> to get full transcripts.</summary>
public async Task<JObject> AssistentListChats(Session session, bool with_messages = false, int limit = MaxChatsOnDisk)
{
await Task.CompletedTask;
try
{
List<JObject> 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<JObject> 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,
};
}
/// <summary>Writes one chat to <c>Assistent/chats/&lt;id&gt;.json</c>.
/// SwarmUI hands the whole request body to a JObject param, so <c>messages</c> (array)
/// and <c>params</c> (object) are read out of <paramref name="raw"/>.</summary>
public async Task<JObject> 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<long?>() ?? existing?["createdAt"]?.Value<long?>() ?? now,
["updatedAt"] = raw?["updatedAt"]?.Value<long?>() ?? 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<JObject> 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<JObject> 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<JObject> 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}" };
}
}
/// <summary>Persists the whitelisted UI preferences. Reads <c>ui_state</c> from the body,
/// falling back to the flat body for convenience.</summary>
public async Task<JObject> 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}" };
}
}
}
+118
View File
@@ -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;
/// <summary>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.</summary>
public partial class SwarmAssistentExtension
{
const string WarmKeepAlive = "15m";
/// <summary>Unloads the chat model from VRAM (<c>keep_alive: 0</c>) so Krea 2 gets the whole GPU.</summary>
public async Task<JObject> 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 };
}
/// <summary>Single-token chat so the model is resident again by the time the user types.</summary>
public async Task<JObject> 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);
}
}
}
+184
View File
@@ -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;
/// <summary>Queue of models the assistant wants downloaded (merged into gpu-rent models.yaml on next up/capture).</summary>
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<JObject> AssistentEnqueueWanted(Session session, string kind, string url, int version_id = 0, string title = null, JObject card = null)
{
await Task.CompletedTask;
kind = (kind ?? "lora").Trim().ToLowerInvariant();
if (kind is not ("lora" or "checkpoint" or "vae" or "embedding" or "controlnet" or "upscaler" or "clip"))
{
kind = "lora";
}
url = (url ?? "").Trim();
if (string.IsNullOrWhiteSpace(url) && version_id > 0)
{
url = $"https://civitai.red/models/0?modelVersionId={version_id}";
}
if (string.IsNullOrWhiteSpace(url))
{
return new JObject { ["error"] = "url or version_id required" };
}
if (version_id <= 0)
{
Match m = Regex.Match(url, @"modelVersionId=(\d+)", RegexOptions.IgnoreCase);
if (m.Success)
{
version_id = int.Parse(m.Groups[1].Value);
}
}
string path = WantedModelsPath();
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? DataRoot());
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : "");
if (version_id > 0)
{
foreach (List<WantedEntry> list in sections.Values)
{
if (list.Any(e => e.VersionId == version_id))
{
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path, ["version_id"] = version_id };
}
}
}
else
{
foreach (List<WantedEntry> list in sections.Values)
{
if (list.Any(e => string.Equals(e.Url, url, StringComparison.OrdinalIgnoreCase)))
{
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path };
}
}
}
if (!sections.TryGetValue(kind, out List<WantedEntry> bucket))
{
bucket = [];
sections[kind] = bucket;
}
bucket.Add(new WantedEntry { Url = url, Title = title, VersionId = version_id });
File.WriteAllText(path, WriteWantedYaml(sections), Encoding.UTF8);
if (card is not null && version_id > 0)
{
Directory.CreateDirectory(WantedCardsDir());
string draft = Path.Combine(WantedCardsDir(), $"{version_id}.assistent.json");
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
}
return new JObject { ["success"] = true, ["path"] = path, ["version_id"] = version_id };
}
sealed class WantedEntry
{
public string Url;
public string Title;
public int VersionId;
}
static Dictionary<string, List<WantedEntry>> LoadWantedYaml(string raw)
{
Dictionary<string, List<WantedEntry>> sections = new(StringComparer.OrdinalIgnoreCase);
string currentKind = null;
WantedEntry cur = null;
void Flush()
{
if (cur is null || string.IsNullOrWhiteSpace(cur.Url) || string.IsNullOrWhiteSpace(currentKind))
{
cur = null;
return;
}
if (!sections.TryGetValue(currentKind, out List<WantedEntry> list))
{
list = [];
sections[currentKind] = list;
}
list.Add(cur);
cur = null;
}
foreach (string line in (raw ?? "").Split('\n'))
{
string t = line.TrimEnd();
if (string.IsNullOrWhiteSpace(t) || t.TrimStart().StartsWith('#'))
{
continue;
}
Match kindLine = Regex.Match(t, @"^([A-Za-z0-9_-]+):\s*$");
if (kindLine.Success && !t.TrimStart().StartsWith('-'))
{
Flush();
currentKind = kindLine.Groups[1].Value.Trim().ToLowerInvariant();
continue;
}
Match urlLine = Regex.Match(t, @"^\s*-\s*url:\s*[""']?(.+?)[""']?\s*$");
if (urlLine.Success)
{
Flush();
cur = new WantedEntry { Url = urlLine.Groups[1].Value.Trim() };
continue;
}
if (cur is null)
{
continue;
}
Match titleLine = Regex.Match(t, @"^\s*title:\s*[""']?(.+?)[""']?\s*$");
if (titleLine.Success)
{
cur.Title = titleLine.Groups[1].Value.Trim();
continue;
}
Match vidLine = Regex.Match(t, @"^\s*version_id:\s*(\d+)\s*$");
if (vidLine.Success && int.TryParse(vidLine.Groups[1].Value, out int vid))
{
cur.VersionId = vid;
}
}
Flush();
return sections;
}
static string WriteWantedYaml(Dictionary<string, List<WantedEntry>> sections)
{
StringBuilder sb = new();
sb.AppendLine("# Assistent wanted queue — merged into local models.yaml on gpu-rent up/capture");
string[] order = ["checkpoint", "lora", "vae", "embedding", "controlnet", "upscaler", "clip"];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (string kind in order.Concat(sections.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)))
{
if (!seen.Add(kind) || !sections.TryGetValue(kind, out List<WantedEntry> list) || list.Count == 0)
{
continue;
}
sb.AppendLine($"{kind}:");
foreach (WantedEntry e in list)
{
sb.AppendLine($" - url: \"{e.Url.Replace("\"", "%22")}\"");
if (!string.IsNullOrWhiteSpace(e.Title))
{
sb.AppendLine($" title: \"{e.Title.Replace("\"", "'")}\"");
}
if (e.VersionId > 0)
{
sb.AppendLine($" version_id: {e.VersionId}");
}
}
}
return sb.ToString();
}
}
+5 -5
View File
@@ -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). 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. 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. 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 users 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 users param request.
7. Guesses — last resort only. 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. 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. - Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words. - 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). - `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. - `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`. - `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. - `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, "pack": null,
"actions": ["generate"], "actions": ["generate"],
"search_query": null, "search_query": null,
"memories": [{"kind": "lora", "key": "name", "text": "fact"}], "memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
"notes": "one-line why" "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. - `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
- `pack` — switch active prompt pack for a follow-up hop. - `pack` — switch active prompt pack for a follow-up hop.
- Do not invent model or LoRA filenames. - 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) ### 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). - `"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. - `"search_civitai"` — Civitai search; user Confirms downloads.
- `"interrupt"` — stop generation. - `"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. - `look_at: ["generate", "ref1"]` — vision hop.
- Pure Q&A with no change: omit the JSON patch. - Pure Q&A with no change: omit the JSON patch.
+5 -3
View File
@@ -3,7 +3,7 @@
You have two memory layers: 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. 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 ## 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). - Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
- Bad paths / pitfalls you discovered this session. - 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 ## 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 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 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. - 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.
@@ -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."
}
]
+42 -11
View File
@@ -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. 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 ## 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 - **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) - **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
## Config (bundled + overlay) ## Config (bundled + overlay)
@@ -16,12 +16,23 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
``` ```
Config/ Config/
_base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity) _base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
personas/<id>/ # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / overrides personas/<id>/ # 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/<id>/…` files to override any bundled preset. Plus this extension's own state:
Copy `personas/cinema/``noir/`, edit only differing JSON files. ```
Assistent/
_base/ personas/<id>/ # 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/<id>.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/<id>/` — the old flat `personas.json` is legacy and only read when no overlay folder exists for that id.
## Exact memory (KV) ## Exact memory (KV)
@@ -33,18 +44,34 @@ Copy `personas/cinema/` → `noir/`, edit only differing JSON files.
## Vector memory ## 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/<id>/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 ⚙) - 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 - 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/<id>.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 ## UX
- **Send to Assistent** under Generate/History → Ref + Assistent tab - **Send to Assistent** under Generate/History → Ref + Assistent tab
- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch - Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path - 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) ### Slash commands (client-side, no LLM)
@@ -103,10 +130,14 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentGetPacks` | Prompt pack texts | | `AssistentGetPacks` | Prompt pack texts |
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) | | `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash | | `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
| `AssistentEnqueueWanted` | Wanted YAML queue | | `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile | | `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
| `AssistentSearchCivitai` | Civitai LoRA search | | `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) | | `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) |
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/<id>.json` |
| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` |
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
## License ## License
+53 -1767
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -18,6 +18,7 @@
<div class="sa-board" id="sa_board" tabindex="0" title="Перетащи изображение на окно или вставь (Ctrl+V). Кликни, чтобы выбрать."></div> <div class="sa-board" id="sa_board" tabindex="0" title="Перетащи изображение на окно или вставь (Ctrl+V). Кликни, чтобы выбрать."></div>
<div class="sa-live-params" id="sa_live_params" title="Текущие параметры Generate"></div> <div class="sa-live-params" id="sa_live_params" title="Текущие параметры Generate"></div>
<div class="sa-image-actions" id="sa_image_actions"> <div class="sa-image-actions" id="sa_image_actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_look_result" title="Прикрепить готовый кадр Generate и спросить мнение">Посмотри результат</button>
<button type="button" class="basic-button" id="sa_btn_use_current" title="Скопировать текущий Generate в Ref">Снимок gen</button> <button type="button" class="basic-button" id="sa_btn_use_current" title="Скопировать текущий Generate в Ref">Снимок gen</button>
<button type="button" class="basic-button" id="sa_btn_clear_image" title="Очистить выбранный Ref">Очистить слот</button> <button type="button" class="basic-button" id="sa_btn_clear_image" title="Очистить выбранный Ref">Очистить слот</button>
<div class="sa-more-wrap" id="sa_board_more_wrap"> <div class="sa-more-wrap" id="sa_board_more_wrap">
@@ -36,6 +37,7 @@
<div class="sa-chat-title"> <div class="sa-chat-title">
Assistent Assistent
<span class="sa-live-dot" id="sa_live_dot" hidden></span> <span class="sa-live-dot" id="sa_live_dot" hidden></span>
<span class="sa-health" id="sa_ollama_health" role="button" tabindex="0" title="Ollama: проверяю…" hidden>Ollama · …</span>
<div class="sa-sessions-bar"> <div class="sa-sessions-bar">
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button> <button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button> <button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
@@ -76,6 +78,18 @@
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button> <button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
<div class="sa-skills-label">Скилы (процедуры)</div> <div class="sa-skills-label">Скилы (процедуры)</div>
<div class="sa-skills-box" id="sa_skills_box"></div> <div class="sa-skills-box" id="sa_skills_box"></div>
<div class="sa-mem-head">
<span class="sa-skills-label">Память</span>
<select id="sa_mem_kind" class="sa-select sa-mem-kind" title="Фильтр по типу">
<option value="all">Все типы</option>
</select>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память и очередь wanted"></button>
</div>
<div class="sa-mem-list" id="sa_mem_list"></div>
<div class="sa-mem-foot">
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span>
<span class="sa-mem-wanted" id="sa_mem_wanted" title="Модели в очереди на следующий gpu-rent up">Очередь wanted: —</span>
</div>
<label class="sa-check"><input type="checkbox" id="sa_auto_vision" /> Авто-прикреплять Generate к чату</label> <label class="sa-check"><input type="checkbox" id="sa_auto_vision" /> Авто-прикреплять Generate к чату</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label> <label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label> <label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
@@ -96,20 +110,7 @@
<span class="sa-elapsed" id="sa_elapsed"></span> <span class="sa-elapsed" id="sa_elapsed"></span>
</div> </div>
<div class="sa-composer" id="sa_composer"> <div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"> <div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
<button type="button" class="sa-chip" data-aspect="1:1" title="1024×1024">1:1</button>
<button type="button" class="sa-chip" data-aspect="4:5" title="928×1152">4:5</button>
<button type="button" class="sa-chip" data-aspect="2:3" title="832×1248">2:3</button>
<button type="button" class="sa-chip" data-aspect="16:9" title="1376×768">16:9</button>
<button type="button" class="sa-chip" data-aspect="9:16" title="768×1376">9:16</button>
<span class="sa-chip-sep" aria-hidden="true"></span>
<button type="button" class="sa-chip" data-seed="lock" title="Оставить текущий seed">Seed lock</button>
<button type="button" class="sa-chip" data-seed="random" title="Случайный seed">Seed 1</button>
<button type="button" class="sa-chip" data-vary="1" title="Тот же промпт, новый seed + generate">Vary</button>
<span class="sa-chip-sep" aria-hidden="true"></span>
<button type="button" class="sa-chip" data-krea-profile="turbo" title="Turbo: steps 8, CFG 1">Turbo</button>
<button type="button" class="sa-chip" data-krea-profile="raw" title="RAW: steps 28, CFG 4.5">RAW</button>
</div>
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div> <div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
<div class="sa-slash-wrap"> <div class="sa-slash-wrap">
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды"></textarea> <textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды"></textarea>