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