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 @@
- +