diff --git a/Assets/assistent.css b/Assets/assistent.css index da0c435..e9f5520 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -556,6 +556,23 @@ font-size: 0.9rem; } +.sa-skills-label { + font-size: 0.85rem; + opacity: 0.85; + margin-top: 0.25rem; +} + +.sa-skills-box { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + margin-bottom: 0.25rem; +} + +.sa-settings .sa-select { + width: 100%; +} + .sa-messages { flex: 1; overflow: auto; diff --git a/Assets/assistent.js b/Assets/assistent.js index e62ab4b..5090a8c 100644 --- a/Assets/assistent.js +++ b/Assets/assistent.js @@ -1,10 +1,11 @@ /** * Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API). - * v0.6.0: board tabs, Cards form+Civitai fetch, LoRA chips, taste on disk, reliability fixes. + * v0.7.0: Config presets, persona folders, vector memory, chat|memory model roles. */ (function () { const LS_BASE = 'swarm_assistent_base_url'; const LS_MODEL = 'swarm_assistent_model'; + const LS_EMBED = 'swarm_assistent_embed_model'; const LS_PACK = 'swarm_assistent_pack'; const LS_PERSONA = 'swarm_assistent_persona'; const LS_VIEW = 'swarm_assistent_view'; @@ -23,7 +24,7 @@ const MAX_REF_SLOTS = 4; const CONTEXT_PROMPT_MAX = 2000; - const ASPECT_TABLE = { + let ASPECT_TABLE = { '1:1': [1024, 1024], '4:3': [1184, 896], '3:2': [1248, 832], @@ -34,7 +35,7 @@ '9:16': [768, 1376], }; - const PACK_ALIASES = { + let PACK_ALIASES = { write: 'write_prompt', write_prompt: 'write_prompt', critique: 'critique_image', @@ -52,7 +53,7 @@ catalog_card: 'catalog_card', }; - const WELCOME_HTML = ` + let WELCOME_HTML = `
Assistent · Krea 2
Напиши, что сгенерировать — или кинь референс и попроси правку.`; - const HELP_TEXT = `Slash-команды (без LLM): + let HELP_TEXT = `Slash-команды (без LLM): /help — этот список /gen — Generate сейчас /look generate|refN — прикрепить окно к vision @@ -78,7 +79,7 @@ Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.`; - const SLASH_COMMANDS = [ + let SLASH_COMMANDS = [ { cmd: '/help', hint: 'список команд' }, { cmd: '/gen', hint: 'Generate сейчас' }, { cmd: '/look ', hint: 'generate|refN' }, @@ -97,6 +98,10 @@ const state = { history: [], packsLoaded: false, + config: null, + enabledSkills: [], + kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } }, + preferredEmbed: null, busy: false, generating: false, chatEpoch: 0, @@ -1138,11 +1143,23 @@ function onPersonaChanged() { const id = $('sa_persona')?.value || 'neutral'; - const info = (state.personas || []).find((p) => p.id === id); - const title = info?.title || id; saveSettings(); - appendSystemNote(`Тон → ${title}`); - state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`; + loadConfig(id, (data) => { + const title = data?.personas?.find((p) => p.id === id)?.title + || (state.personas || []).find((p) => p.id === id)?.title + || id; + if (data?.personas) { + state.personas = data.personas; + } + appendSystemNote(`Тон → ${title}`); + state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`; + if (data?.assistant?.default_pack && $('sa_pack') && !state.packUserTouched) { + const packId = data.assistant.default_pack; + if ([...($('sa_pack').options || [])].some((o) => o.value === packId)) { + $('sa_pack').value = packId; + } + } + }); } function countPromptImages() { @@ -2765,6 +2782,10 @@ if (model) { state.preferredModel = model; } + const embed = localStorage.getItem(LS_EMBED); + if (embed) { + state.preferredEmbed = embed; + } if (paneW) { document.documentElement.style.setProperty('--sa-image-width', paneW); } @@ -2780,6 +2801,7 @@ function saveSettings() { localStorage.setItem(LS_BASE, $('sa_base_url')?.value || ''); localStorage.setItem(LS_MODEL, $('sa_model')?.value || ''); + localStorage.setItem(LS_EMBED, $('sa_embed_model')?.value || state.preferredEmbed || ''); localStorage.setItem(LS_PACK, $('sa_pack')?.value || 'write_prompt'); localStorage.setItem(LS_PERSONA, $('sa_persona')?.value || 'neutral'); localStorage.setItem(LS_VIEW, state.view || 'chat'); @@ -2788,6 +2810,193 @@ localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0'); localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0'); + persistServerSettings(); + } + + function persistServerSettings() { + if (typeof genericRequest !== 'function') { + return; + } + const skills = {}; + document.querySelectorAll('#sa_skills_box input[data-skill]')?.forEach((el) => { + skills[el.getAttribute('data-skill')] = !!el.checked; + }); + const persona = $('sa_persona')?.value || 'neutral'; + const settings = { + embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '', + base_url: $('sa_base_url')?.value || '', + [persona]: { skills }, + }; + genericRequest('AssistentSaveSettings', { settings }, () => {}, 0, () => {}); + } + + function applyConfigPayload(data, { applyDefaults = false } = {}) { + if (!data || data.error) { + return; + } + state.config = data; + if (data.model?.aspect_table && typeof data.model.aspect_table === 'object') { + const next = {}; + for (const [k, v] of Object.entries(data.model.aspect_table)) { + if (Array.isArray(v) && v.length >= 2) { + next[k] = [Number(v[0]), Number(v[1])]; + } + } + if (Object.keys(next).length) { + ASPECT_TABLE = next; + } + } + if (data.model?.profiles) { + state.kreaProfiles = data.model.profiles; + } + if (data.ui?.pack_aliases) { + PACK_ALIASES = { ...PACK_ALIASES, ...data.ui.pack_aliases }; + } + if (data.ui?.welcome_html) { + WELCOME_HTML = data.ui.welcome_html; + } + if (data.ui?.help_text) { + HELP_TEXT = data.ui.help_text; + } + if (Array.isArray(data.ui?.slash) && data.ui.slash.length) { + SLASH_COMMANDS = data.ui.slash.map((s) => ({ + cmd: s.cmd || '', + hint: s.hint || '', + action: s.action || '', + })); + } + state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : []; + if (Array.isArray(data.personas)) { + state.personas = data.personas; + } + renderPersonaOptions(data.personas || [], data.persona || data.default_persona); + renderPackOptions(data.packs || [], applyDefaults ? data.assistant?.default_pack : null); + renderChips(data.ui?.chips || []); + renderSkillChecks(data.skills || [], state.enabledSkills); + if (applyDefaults && data.assistant?.default_pack && $('sa_pack') && !localStorage.getItem(LS_PACK)) { + $('sa_pack').value = data.assistant.default_pack; + } + if (data.assistant?.embed_model && !state.preferredEmbed) { + state.preferredEmbed = data.assistant.embed_model; + } + } + + function renderPersonaOptions(personas, selected) { + const sel = $('sa_persona'); + if (!sel) { + return; + } + const cur = selected || sel.value || localStorage.getItem(LS_PERSONA) || 'neutral'; + sel.innerHTML = ''; + for (const p of personas) { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.title || p.id; + if (p.accent) { + opt.dataset.accent = p.accent; + } + sel.appendChild(opt); + } + if ([...sel.options].some((o) => o.value === cur)) { + sel.value = cur; + } + } + + function renderPackOptions(packs, preferred) { + const sel = $('sa_pack'); + if (!sel) { + return; + } + const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || 'write_prompt'; + sel.innerHTML = ''; + const list = (packs || []).slice().sort((a, b) => (a.order || 100) - (b.order || 100)); + for (const p of list) { + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = p.title || p.id; + sel.appendChild(opt); + } + if ([...sel.options].some((o) => o.value === cur)) { + sel.value = cur; + } + } + + function renderChips(chips) { + const box = $('sa_chips'); + if (!box || !Array.isArray(chips) || !chips.length) { + return; + } + box.innerHTML = ''; + for (const c of chips) { + if (c.sep) { + const sep = document.createElement('span'); + sep.className = 'sa-chip-sep'; + sep.setAttribute('aria-hidden', 'true'); + box.appendChild(sep); + continue; + } + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'sa-chip'; + btn.textContent = c.label || c.value || ''; + if (c.title) { + btn.title = c.title; + } + const action = c.action || ''; + const value = c.value ?? ''; + if (action === 'aspect') { + btn.setAttribute('data-aspect', value); + } else if (action === 'seed') { + btn.setAttribute('data-seed', value); + } else if (action === 'vary') { + btn.setAttribute('data-vary', value || '1'); + } else if (action === 'krea_profile') { + btn.setAttribute('data-krea-profile', value); + } + box.appendChild(btn); + } + } + + function renderSkillChecks(skills, enabled) { + const box = $('sa_skills_box'); + if (!box) { + return; + } + const on = new Set(enabled || []); + box.innerHTML = ''; + for (const s of skills || []) { + const label = document.createElement('label'); + label.className = 'sa-check'; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.setAttribute('data-skill', s.id); + input.checked = on.has(s.id) || (!enabled?.length && !!s.default); + input.addEventListener('change', () => { + state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill')); + saveSettings(); + }); + label.appendChild(input); + label.appendChild(document.createTextNode(` ${s.title || s.id}`)); + box.appendChild(label); + } + state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill')); + } + + function loadConfig(persona, done) { + if (typeof genericRequest !== 'function') { + done?.(null); + return; + } + genericRequest( + 'AssistentGetConfig', + { persona: persona || $('sa_persona')?.value || 'neutral' }, + (data) => { + applyConfigPayload(data, { applyDefaults: true }); + done?.(data); + }, + 0, + () => done?.(null), + ); } function setModelOptions(models, { error } = {}) { @@ -2825,6 +3034,38 @@ } } + function setEmbedModelOptions(models) { + const sel = $('sa_embed_model'); + if (!sel) { + return; + } + const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean); + sel.innerHTML = ''; + if (!names.length) { + const opt = document.createElement('option'); + opt.value = state.preferredEmbed || 'nomic-embed-text'; + opt.textContent = opt.value + ' (ожидается pull)'; + sel.appendChild(opt); + return; + } + for (const name of names) { + const opt = document.createElement('option'); + opt.value = name; + opt.textContent = name; + sel.appendChild(opt); + } + const prefer = state.preferredEmbed || localStorage.getItem(LS_EMBED) || state.config?.assistant?.embed_model; + if (prefer && names.includes(prefer)) { + sel.value = prefer; + } else if (prefer && !names.includes(prefer)) { + const opt = document.createElement('option'); + opt.value = prefer; + opt.textContent = prefer; + sel.appendChild(opt); + sel.value = prefer; + } + } + function refreshModels() { const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434'; setStatus('Loading models…'); @@ -2838,12 +3079,14 @@ { baseUrl }, (data) => { const models = data.models || []; + const memoryModels = data.memory_models || []; setModelOptions(models); + setEmbedModelOptions(memoryModels); const prefer = state.preferredModel || localStorage.getItem(LS_MODEL); if (prefer && models.includes(prefer) && $('sa_model')) { $('sa_model').value = prefer; } - setStatus(models.length ? `${models.length} models` : 'No Ollama models (gpu-rent: ollama pull)'); + setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)'); saveSettings(); }, 0, @@ -2928,36 +3171,11 @@ } function refreshPersonas() { - if (typeof genericRequest !== 'function') { - return; - } - genericRequest( - 'AssistentListPersonas', - {}, - (data) => { - const list = data.personas || []; - state.personas = list; - const sel = $('sa_persona'); - if (!sel) { - return; - } - const prefer = localStorage.getItem(LS_PERSONA) || data.default || 'neutral'; - sel.innerHTML = ''; - for (const p of list) { - const opt = document.createElement('option'); - opt.value = p.id; - opt.textContent = p.title || p.id; - sel.appendChild(opt); - } - if ([...sel.options].some((o) => o.value === prefer)) { - sel.value = prefer; - } else if (data.default) { - sel.value = data.default; - } - }, - 0, - (err) => console.warn('Assistent personas', err), - ); + loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => { + if (data?.personas) { + state.personas = data.personas; + } + }); } function prefetchCard(kind, name) { @@ -4132,6 +4350,8 @@ includeBase: true, messages, context_json: JSON.stringify(context), + skills: state.enabledSkills || [], + embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '', raw: { messages, context_json: JSON.stringify(context), @@ -4139,6 +4359,8 @@ persona, base_url: baseUrl, model, + skills: state.enabledSkills || [], + embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '', }, }; @@ -4431,11 +4653,12 @@ } restoreHistory(); maybeWelcome(); - refreshModels(); - refreshPersonas(); - refreshInventory(() => { - renderCardsList(); - renderLoraChips(); + loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => { + refreshModels(); + refreshInventory(() => { + renderCardsList(); + renderLoraChips(); + }); }); wireDropZone(); wireSplitter(); @@ -4537,6 +4760,10 @@ }); $('sa_base_url')?.addEventListener('change', saveSettings); $('sa_model')?.addEventListener('change', saveSettings); + $('sa_embed_model')?.addEventListener('change', () => { + state.preferredEmbed = $('sa_embed_model')?.value || ''; + saveSettings(); + }); $('sa_pack')?.addEventListener('change', () => { state.packUserTouched = true; saveSettings(); @@ -4559,9 +4786,11 @@ } else if (vary) { await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary'); } else if (profile === 'turbo') { - await applyQuickPatch({ steps: 8, cfg: 1, sigma_shift: 1.15, actions: ['generate'] }, 'Turbo 8/1'); + const p = state.kreaProfiles?.turbo || { steps: 8, cfg: 1, sigma_shift: 1.15 }; + await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo'); } else if (profile === 'raw') { - await applyQuickPatch({ steps: 28, cfg: 4.5, actions: ['generate'] }, 'RAW 28/4.5'); + const p = state.kreaProfiles?.raw || { steps: 28, cfg: 4.5 }; + await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, actions: ['generate'] }, 'RAW'); } renderLoraChips(); }); diff --git a/AssistentConfig.cs b/AssistentConfig.cs new file mode 100644 index 0000000..ea862ce --- /dev/null +++ b/AssistentConfig.cs @@ -0,0 +1,752 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using FreneticUtilities.FreneticExtensions; +using Newtonsoft.Json.Linq; +using SwarmUI.Utils; + +namespace Mrleo1nid.SwarmAssistent; + +/// Loads Config/_base + personas/<id> sparse presets with disk overlay merge. +public sealed class AssistentConfig +{ + readonly string _bundledRoot; + readonly string _overlayRoot; + readonly object _lock = new(); + + public AssistentConfig(string extensionFilePath, string dataRoot) + { + _bundledRoot = Path.Combine(extensionFilePath ?? "", "Config"); + _overlayRoot = Path.Combine(dataRoot ?? "", "Assistent"); + } + + public string BundledRoot => _bundledRoot; + public string OverlayRoot => _overlayRoot; + + public static string SafeId(string id) + { + string s = (id ?? "").Replace('\\', '/').AfterLast('/').Replace("..", "").Trim(); + if (string.IsNullOrWhiteSpace(s) || !Regex.IsMatch(s, @"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$")) + { + return null; + } + return s; + } + + static string SafeRel(string relative) + { + if (string.IsNullOrWhiteSpace(relative)) + { + return null; + } + string norm = relative.Replace('\\', '/').TrimStart('/'); + if (norm.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)) + { + return null; + } + return norm.Replace('/', Path.DirectorySeparatorChar); + } + + public string ResolveUnder(string root, string relative) + { + string rel = SafeRel(relative); + if (rel is null || string.IsNullOrWhiteSpace(root)) + { + return null; + } + string full = Path.GetFullPath(Path.Combine(root, rel)); + string rootFull = Path.GetFullPath(root); + if (!full.StartsWith(rootFull.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) + && !string.Equals(full, rootFull, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + return full; + } + + public JObject DeepMerge(JObject bottom, JObject top) + { + if (bottom is null) + { + return top is null ? new JObject() : (JObject)top.DeepClone(); + } + if (top is null) + { + return (JObject)bottom.DeepClone(); + } + JObject result = (JObject)bottom.DeepClone(); + foreach (JProperty prop in top.Properties()) + { + if (prop.Value is JObject topObj && result[prop.Name] is JObject botObj) + { + result[prop.Name] = DeepMerge(botObj, topObj); + } + else if (prop.Value is JArray || prop.Value is null || prop.Value.Type == JTokenType.Null) + { + // Arrays replace entirely when the top file provides them. + if (prop.Value is not null && prop.Value.Type != JTokenType.Null) + { + result[prop.Name] = prop.Value.DeepClone(); + } + } + else if (prop.Value.Type == JTokenType.String && string.IsNullOrWhiteSpace(prop.Value.ToString())) + { + // Empty string does not clobber (personas.json empty prompt rule). + continue; + } + else + { + result[prop.Name] = prop.Value.DeepClone(); + } + } + return result; + } + + public JObject TryReadJson(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + try + { + return JObject.Parse(File.ReadAllText(path, Encoding.UTF8)); + } + catch (Exception ex) + { + Logs.Debug($"AssistentConfig json {path}: {ex.Message}"); + return null; + } + } + + public JArray TryReadJsonArray(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + try + { + return JArray.Parse(File.ReadAllText(path, Encoding.UTF8)); + } + catch (Exception ex) + { + Logs.Debug($"AssistentConfig json-array {path}: {ex.Message}"); + return null; + } + } + + public string TryReadText(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + try + { + string text = File.ReadAllText(path, Encoding.UTF8); + return string.IsNullOrWhiteSpace(text) ? null : text; + } + catch (Exception ex) + { + Logs.Debug($"AssistentConfig text {path}: {ex.Message}"); + return null; + } + } + + /// Merge layered copies of the same relative path: bundled base → persona chain → disk base → disk persona. + public JObject MergeJsonLayers(string relative, IEnumerable roots) + { + JObject acc = null; + foreach (string root in roots) + { + string path = ResolveUnder(root, relative); + JObject next = TryReadJson(path); + if (next is null) + { + continue; + } + acc = DeepMerge(acc, next); + } + return acc ?? new JObject(); + } + + public string MergeTextLayers(string relative, IEnumerable roots) + { + string last = null; + foreach (string root in roots) + { + string path = ResolveUnder(root, relative); + string text = TryReadText(path); + if (text is not null) + { + last = text; + } + } + return last; + } + + List PersonaExtendsChain(string personaId) + { + List chain = []; + HashSet seen = new(StringComparer.OrdinalIgnoreCase); + string cur = SafeId(personaId) ?? "neutral"; + for (int i = 0; i < 8 && !string.IsNullOrWhiteSpace(cur); i++) + { + if (!seen.Add(cur)) + { + break; + } + chain.Insert(0, cur); + JObject meta = TryReadJson(ResolveUnder(Path.Combine(_bundledRoot, "personas", cur), "persona.json")) + ?? TryReadJson(ResolveUnder(Path.Combine(_overlayRoot, "personas", cur), "persona.json")); + string parent = SafeId(meta?["extends"]?.ToString()); + if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, cur, StringComparison.OrdinalIgnoreCase)) + { + break; + } + cur = parent; + } + return chain; + } + + public IEnumerable LayerRoots(string personaId) + { + yield return Path.Combine(_bundledRoot, "_base"); + foreach (string id in PersonaExtendsChain(personaId)) + { + yield return Path.Combine(_bundledRoot, "personas", id); + } + yield return Path.Combine(_overlayRoot, "_base"); + foreach (string id in PersonaExtendsChain(personaId)) + { + yield return Path.Combine(_overlayRoot, "personas", id); + } + } + + public List<(string id, string title, string accent, string source)> ListPersonaCatalog() + { + Dictionary byId = new(StringComparer.OrdinalIgnoreCase); + void Scan(string root, string source) + { + string dir = Path.Combine(root, "personas"); + if (!Directory.Exists(dir)) + { + return; + } + foreach (string folder in Directory.GetDirectories(dir)) + { + string id = SafeId(Path.GetFileName(folder)); + if (id is null) + { + continue; + } + JObject meta = TryReadJson(Path.Combine(folder, "persona.json")); + if (meta is null) + { + continue; + } + if (meta["enabled"]?.Value() == false) + { + byId.Remove(id); + continue; + } + byId[id] = ( + meta["title"]?.ToString() ?? id, + meta["accent"]?.ToString() ?? "#8b949e", + source + ); + } + } + Scan(_bundledRoot, "bundled"); + Scan(_overlayRoot, "overlay"); + + // Legacy personas.json titles + string overlayJson = Path.Combine(_overlayRoot, "personas.json"); + JObject legacy = TryReadJson(overlayJson); + if (legacy?["personas"] is JArray arr) + { + foreach (JToken t in arr) + { + if (t is not JObject po) + { + continue; + } + string id = SafeId(po["id"]?.ToString()); + if (id is null) + { + continue; + } + if (byId.TryGetValue(id, out var cur)) + { + byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, "overlay+bundled"); + } + else + { + byId[id] = (po["title"]?.ToString() ?? id, "#8b949e", "legacy"); + } + } + } + + return byId.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) + .Select(kv => (kv.Key, kv.Value.title, kv.Value.accent, kv.Value.source)) + .ToList(); + } + + public string DefaultPersonaId() + { + JObject assistant = MergeJsonLayers("assistant.json", LayerRoots("neutral")); + string def = SafeId(assistant["default_persona"]?.ToString()) ?? "neutral"; + string overlayJson = Path.Combine(_overlayRoot, "personas.json"); + JObject legacy = TryReadJson(overlayJson); + string fromLegacy = SafeId(legacy?["default"]?.ToString()); + if (fromLegacy is not null) + { + def = fromLegacy; + } + var catalog = ListPersonaCatalog(); + if (catalog.All(p => !string.Equals(p.id, def, StringComparison.OrdinalIgnoreCase)) && catalog.Count > 0) + { + def = catalog[0].id; + } + return def; + } + + public JObject LoadAssistant(string personaId) => MergeJsonLayers("assistant.json", LayerRoots(personaId)); + + public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId)); + + public JObject LoadModelProfile(string personaId) + { + JObject assistant = LoadAssistant(personaId); + string arch = assistant["gate"]?["architecture"]?.ToString() ?? "krea2"; + string safe = SafeId(arch) ?? "krea2"; + return MergeJsonLayers(Path.Combine("models", $"{safe}.json"), LayerRoots(personaId)); + } + + public string LoadCorePrompt(string personaId) + { + JObject meta = MergeJsonLayers(Path.Combine("core", "core.json"), LayerRoots(personaId)); + string file = meta["prompt_file"]?.ToString() ?? "core.md"; + return MergeTextLayers(Path.Combine("core", file), LayerRoots(personaId)) ?? ""; + } + + public List<(string id, string title, int order, string[] aliases, bool enabled)> ListPacks(string personaId) + { + Dictionary byId = new(StringComparer.OrdinalIgnoreCase); + foreach (string root in LayerRoots(personaId)) + { + string dir = Path.Combine(root, "packs"); + if (!Directory.Exists(dir)) + { + continue; + } + foreach (string file in Directory.GetFiles(dir, "*.json")) + { + JObject meta = TryReadJson(file); + string id = SafeId(meta?["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file)); + if (id is null || meta is null) + { + continue; + } + bool enabled = meta["enabled"]?.Value() != false; + string[] aliases = (meta["aliases"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).ToArray() ?? []; + byId[id] = ( + meta["title"]?.ToString() ?? id, + meta["order"]?.Value() ?? 100, + aliases, + enabled + ); + } + } + return byId.Where(kv => kv.Value.enabled) + .OrderBy(kv => kv.Value.order).ThenBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) + .Select(kv => (kv.Key, kv.Value.title, kv.Value.order, kv.Value.aliases, kv.Value.enabled)) + .ToList(); + } + + public string LoadPackPrompt(string personaId, string packId) + { + string id = SafeId(packId); + if (id is null) + { + return null; + } + JObject meta = MergeJsonLayers(Path.Combine("packs", $"{id}.json"), LayerRoots(personaId)); + if (meta["enabled"]?.Value() == false) + { + return null; + } + string file = meta["prompt_file"]?.ToString() ?? $"{id}.md"; + return MergeTextLayers(Path.Combine("packs", file), LayerRoots(personaId)); + } + + public List<(string id, string title, bool defaultOn, bool enabled)> ListSkills(string personaId) + { + Dictionary byId = new(StringComparer.OrdinalIgnoreCase); + foreach (string root in LayerRoots(personaId)) + { + string dir = Path.Combine(root, "skills"); + if (!Directory.Exists(dir)) + { + continue; + } + foreach (string file in Directory.GetFiles(dir, "*.json")) + { + JObject meta = TryReadJson(file); + string id = SafeId(meta?["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file)); + if (id is null || meta is null) + { + continue; + } + byId[id] = ( + meta["title"]?.ToString() ?? id, + meta["default"]?.Value() ?? false, + meta["enabled"]?.Value() != false + ); + } + } + JObject skillsOverride = MergeJsonLayers("skills.json", LayerRoots(personaId)); + foreach (JProperty prop in skillsOverride.Properties()) + { + string id = SafeId(prop.Name); + if (id is null || !byId.ContainsKey(id)) + { + continue; + } + if (prop.Value.Type == JTokenType.Boolean) + { + var cur = byId[id]; + byId[id] = (cur.title, prop.Value.Value(), cur.enabled); + } + } + return byId.Where(kv => kv.Value.enabled) + .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase) + .Select(kv => (kv.Key, kv.Value.title, kv.Value.defaultOn, kv.Value.enabled)) + .ToList(); + } + + public string LoadSkillPrompt(string personaId, string skillId) + { + string id = SafeId(skillId); + if (id is null) + { + return null; + } + JObject meta = MergeJsonLayers(Path.Combine("skills", $"{id}.json"), LayerRoots(personaId)); + if (meta["enabled"]?.Value() == false) + { + return null; + } + string file = meta["prompt_file"]?.ToString() ?? $"{id}.md"; + return MergeTextLayers(Path.Combine("skills", file), LayerRoots(personaId)); + } + + public JObject LoadIdentityParts(string personaId) + { + var roots = LayerRoots(personaId).ToList(); + JObject persona = MergeJsonLayers("persona.json", roots); + JObject voice = MergeJsonLayers("voice.json", roots); + JObject likes = MergeJsonLayers("likes.json", roots); + JObject dislikes = MergeJsonLayers("dislikes.json", roots); + 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) + { + string id = SafeId(personaId) ?? "neutral"; + foreach (JToken t in arr) + { + if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase)) + { + 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; + } + } + } + + return new JObject + { + ["persona"] = persona, + ["voice"] = voice, + ["likes"] = likes, + ["dislikes"] = dislikes, + ["rules"] = rules, + ["extra"] = extra ?? "", + }; + } + + static string FormatCategoryMap(JObject obj) + { + if (obj is null || !obj.Properties().Any()) + { + return ""; + } + List parts = []; + foreach (JProperty prop in obj.Properties()) + { + if (prop.Value is JArray arr) + { + string joined = string.Join(", ", arr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s))); + if (!string.IsNullOrWhiteSpace(joined)) + { + parts.Add($"{prop.Name}: {joined}"); + } + } + else if (prop.Value?.Type == JTokenType.String) + { + string s = prop.Value.ToString(); + if (!string.IsNullOrWhiteSpace(s)) + { + parts.Add($"{prop.Name}: {s}"); + } + } + } + return string.Join("; ", parts); + } + + public string RenderIdentityBlock(string personaId) + { + string id = SafeId(personaId) ?? "neutral"; + JObject parts = LoadIdentityParts(id); + JObject persona = parts["persona"] as JObject ?? new JObject(); + JObject voice = parts["voice"] as JObject ?? new JObject(); + JObject likes = parts["likes"] as JObject ?? new JObject(); + JObject dislikes = parts["dislikes"] as JObject ?? new JObject(); + JObject rules = parts["rules"] as JObject ?? new JObject(); + string extra = parts["extra"]?.ToString() ?? ""; + + string title = persona["title"]?.ToString() ?? id; + StringBuilder sb = new(); + sb.AppendLine($"## Persona: {id} — {title}"); + + List voiceBits = []; + if (voice["verbosity"] != null) + { + voiceBits.Add(voice["verbosity"].ToString()); + } + if (voice["tone"] is JArray tones) + { + voiceBits.AddRange(tones.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s))); + } + if (voice["humor"] != null && voice["humor"].ToString() != "none") + { + voiceBits.Add($"humor:{voice["humor"]}"); + } + if (voice["nsfw"] != null) + { + voiceBits.Add($"NSFW {voice["nsfw"]}"); + } + if (voice["language"] != null) + { + voiceBits.Add(voice["language"].ToString()); + } + if (voiceBits.Count > 0) + { + sb.AppendLine("Voice: " + string.Join(", ", voiceBits)); + } + + string prefers = FormatCategoryMap(likes); + if (!string.IsNullOrWhiteSpace(prefers)) + { + sb.AppendLine("Prefers: " + prefers); + } + string avoids = FormatCategoryMap(dislikes); + if (!string.IsNullOrWhiteSpace(avoids)) + { + sb.AppendLine("Avoids: " + avoids); + } + + List ruleBits = []; + if (rules["always"] is JArray always) + { + foreach (string s in always.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x))) + { + ruleBits.Add("always " + s); + } + } + if (rules["never"] is JArray never) + { + foreach (string s in never.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x))) + { + ruleBits.Add("never " + s); + } + } + if (ruleBits.Count > 0) + { + sb.AppendLine("Rules: " + string.Join("; ", ruleBits)); + } + if (!string.IsNullOrWhiteSpace(extra)) + { + sb.AppendLine(extra.Trim()); + } + return sb.ToString().TrimEnd(); + } + + public List LoadMemorySeedDocs() + { + List docs = []; + void Scan(string root) + { + string dir = Path.Combine(root, "memory-seed"); + if (!Directory.Exists(dir)) + { + return; + } + 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) + { + foreach (JToken t in arr) + { + if (t is JObject jo) + { + docs.Add(jo); + } + } + } + else if (parsed is JObject single) + { + docs.Add(single); + } + } + catch (Exception ex) + { + Logs.Debug($"AssistentConfig memory-seed {file}: {ex.Message}"); + } + } + } + Scan(Path.Combine(_bundledRoot, "_base")); + Scan(Path.Combine(_overlayRoot, "_base")); + Scan(Path.Combine(_overlayRoot, "memory-seed")); + return docs; + } + + public JObject LoadSettings() + { + return TryReadJson(Path.Combine(_overlayRoot, "settings.json")) ?? new JObject(); + } + + public void SaveSettings(JObject settings) + { + Directory.CreateDirectory(_overlayRoot); + string path = Path.Combine(_overlayRoot, "settings.json"); + File.WriteAllText(path, (settings ?? new JObject()).ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); + } + + public JObject LoadOllamaRoles() + { + return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json")) + ?? new JObject { ["chat"] = new JArray(), ["memory"] = new JArray() }; + } + + public List ResolveEnabledSkills(string personaId, JArray clientSkills) + { + var catalog = ListSkills(personaId); + HashSet enabled = new(StringComparer.OrdinalIgnoreCase); + foreach (var s in catalog.Where(x => x.defaultOn)) + { + enabled.Add(s.id); + } + JObject settings = LoadSettings(); + string pid = SafeId(personaId) ?? "neutral"; + if (settings[pid]?["skills"] is JObject perPersona) + { + foreach (JProperty prop in perPersona.Properties()) + { + string id = SafeId(prop.Name); + if (id is null) + { + continue; + } + if (prop.Value.Type == JTokenType.Boolean) + { + if (prop.Value.Value()) + { + enabled.Add(id); + } + else + { + enabled.Remove(id); + } + } + } + } + if (clientSkills is not null && clientSkills.Count > 0) + { + enabled.Clear(); + foreach (JToken t in clientSkills) + { + string id = SafeId(t?.ToString()); + if (id is not null && catalog.Any(c => string.Equals(c.id, id, StringComparison.OrdinalIgnoreCase))) + { + enabled.Add(id); + } + } + } + return catalog.Select(c => c.id).Where(enabled.Contains).ToList(); + } + + public JObject BuildMergedConfigPayload(string personaId) + { + string id = SafeId(personaId) ?? DefaultPersonaId(); + JObject assistant = LoadAssistant(id); + JObject ui = LoadUi(id); + JObject model = LoadModelProfile(id); + var packs = ListPacks(id); + var skills = ListSkills(id); + var personas = ListPersonaCatalog(); + JObject identity = LoadIdentityParts(id); + return new JObject + { + ["success"] = true, + ["persona"] = id, + ["default_persona"] = DefaultPersonaId(), + ["assistant"] = assistant, + ["ui"] = ui, + ["model"] = model, + ["packs"] = new JArray(packs.Select(p => new JObject + { + ["id"] = p.id, + ["title"] = p.title, + ["order"] = p.order, + ["aliases"] = new JArray(p.aliases), + })), + ["skills"] = new JArray(skills.Select(s => new JObject + { + ["id"] = s.id, + ["title"] = s.title, + ["default"] = s.defaultOn, + })), + ["personas"] = new JArray(personas.Select(p => new JObject + { + ["id"] = p.id, + ["title"] = p.title, + ["accent"] = p.accent, + ["source"] = p.source, + })), + ["identity"] = identity, + ["identity_summary"] = RenderIdentityBlock(id), + ["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)), + }; + } +} diff --git a/AssistentMemory.cs b/AssistentMemory.cs new file mode 100644 index 0000000..7c931f2 --- /dev/null +++ b/AssistentMemory.cs @@ -0,0 +1,428 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Newtonsoft.Json.Linq; +using SwarmUI.Utils; + +namespace Mrleo1nid.SwarmAssistent; + +/// Local SQLite vector memory with Ollama /api/embed. +public sealed class AssistentMemory : IDisposable +{ + readonly string _dbPath; + readonly HttpClient _http; + readonly object _lock = new(); + SqliteConnection _conn; + string _embedModel; + int _dims; + int _seedVersion; + + public AssistentMemory(string dataRoot, HttpClient http, string defaultEmbedModel = "nomic-embed-text") + { + string dir = Path.Combine(dataRoot ?? ".", "Assistent", "memory"); + Directory.CreateDirectory(dir); + _dbPath = Path.Combine(dir, "assistent.sqlite"); + _http = http; + _embedModel = string.IsNullOrWhiteSpace(defaultEmbedModel) ? "nomic-embed-text" : defaultEmbedModel.Trim(); + } + + public string EmbedModel => _embedModel; + public int Dims => _dims; + public int SeedVersion => _seedVersion; + + void EnsureOpen() + { + if (_conn is not null) + { + return; + } + _conn = new SqliteConnection($"Data Source={_dbPath}"); + _conn.Open(); + using (SqliteCommand cmd = _conn.CreateCommand()) + { + cmd.CommandText = + """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + key TEXT NOT NULL, + text TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'user', + meta_json TEXT, + embedding BLOB, + updated INTEGER NOT NULL, + UNIQUE(kind, key, source) + ); + CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind); + """; + cmd.ExecuteNonQuery(); + } + _embedModel = GetMeta("embed_model") ?? _embedModel; + _ = int.TryParse(GetMeta("dims"), out _dims); + _ = int.TryParse(GetMeta("seed_version"), out _seedVersion); + } + + string GetMeta(string key) + { + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT value FROM meta WHERE key = $k"; + cmd.Parameters.AddWithValue("$k", key); + return cmd.ExecuteScalar()?.ToString(); + } + + void SetMeta(string key, string value) + { + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "INSERT INTO meta(key, value) VALUES($k, $v) ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + cmd.Parameters.AddWithValue("$k", key); + cmd.Parameters.AddWithValue("$v", value ?? ""); + cmd.ExecuteNonQuery(); + } + + static byte[] FloatsToBytes(float[] v) + { + byte[] bytes = new byte[v.Length * 4]; + Buffer.BlockCopy(v, 0, bytes, 0, bytes.Length); + return bytes; + } + + static float[] BytesToFloats(byte[] bytes) + { + if (bytes is null || bytes.Length < 4 || bytes.Length % 4 != 0) + { + return Array.Empty(); + } + float[] v = new float[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, v, 0, bytes.Length); + return v; + } + + static float Cosine(float[] a, float[] b) + { + if (a.Length == 0 || a.Length != b.Length) + { + return float.NegativeInfinity; + } + double dot = 0, na = 0, nb = 0; + for (int i = 0; i < a.Length; i++) + { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (na <= 0 || nb <= 0) + { + return float.NegativeInfinity; + } + return (float)(dot / (Math.Sqrt(na) * Math.Sqrt(nb))); + } + + public async Task EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m") + { + string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/'); + string m = string.IsNullOrWhiteSpace(model) ? _embedModel : model.Trim(); + JObject payload = new() + { + ["model"] = m, + ["input"] = text ?? "", + ["keep_alive"] = keepAlive, + }; + using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); + using HttpResponseMessage resp = await _http.PostAsync($"{root}/api/embed", content); + string body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + throw new Exception($"Ollama /api/embed HTTP {(int)resp.StatusCode}: {body[..Math.Min(body.Length, 200)]}"); + } + JObject parsed = JObject.Parse(body); + JArray embeddings = parsed["embeddings"] as JArray; + JToken first = embeddings?.FirstOrDefault() ?? parsed["embedding"]; + if (first is not JArray vec) + { + throw new Exception("Ollama /api/embed: no embeddings in response"); + } + float[] floats = vec.Select(t => t.Value()).ToArray(); + return floats; + } + + public async Task EnsureSeedAsync(string baseUrl, AssistentConfig config, string modelOverride = null) + { + lock (_lock) + { + EnsureOpen(); + } + JObject assistant = config.LoadAssistant(config.DefaultPersonaId()); + int wantVersion = assistant["seed_version"]?.Value() ?? 1; + string wantModel = string.IsNullOrWhiteSpace(modelOverride) + ? (assistant["embed_model"]?.ToString() ?? _embedModel) + : 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 + float[] probe; + try + { + probe = await EmbedAsync(baseUrl, wantModel, docs[0]["text"]?.ToString() ?? "seed"); + } + catch (Exception ex) + { + Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}"); + return; + } + + lock (_lock) + { + EnsureOpen(); + if (!string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase) || (_dims > 0 && _dims != probe.Length)) + { + using SqliteCommand clear = _conn.CreateCommand(); + clear.CommandText = "DELETE FROM memories"; + clear.ExecuteNonQuery(); + } + else if (needReseed) + { + using SqliteCommand clearBundled = _conn.CreateCommand(); + clearBundled.CommandText = "DELETE FROM memories WHERE source = 'bundled'"; + clearBundled.ExecuteNonQuery(); + } + _embedModel = wantModel; + _dims = probe.Length; + _seedVersion = wantVersion; + SetMeta("embed_model", _embedModel); + SetMeta("dims", _dims.ToString()); + SetMeta("seed_version", _seedVersion.ToString()); + } + + foreach (JObject doc in docs) + { + string kind = (doc["kind"]?.ToString() ?? "note").Trim(); + string key = (doc["key"]?.ToString() ?? "").Trim(); + string text = (doc["text"]?.ToString() ?? "").Trim(); + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text)) + { + continue; + } + try + { + float[] vec = await EmbedAsync(baseUrl, wantModel, text); + Upsert(kind, key, text, "bundled", doc["tags"], vec); + } + catch (Exception ex) + { + Logs.Debug($"AssistentMemory seed item {kind}/{key}: {ex.Message}"); + } + } + } + + public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding) + { + lock (_lock) + { + EnsureOpen(); + kind = (kind ?? "note").Trim().ToLowerInvariant(); + key = (key ?? "").Trim(); + text = (text ?? "").Trim(); + source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim(); + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text)) + { + return; + } + if (embedding is { Length: > 0 }) + { + if (_dims <= 0) + { + _dims = embedding.Length; + SetMeta("dims", _dims.ToString()); + } + } + 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 + text = excluded.text, + meta_json = excluded.meta_json, + embedding = excluded.embedding, + updated = excluded.updated + """; + cmd.Parameters.AddWithValue("$kind", kind); + cmd.Parameters.AddWithValue("$key", key); + cmd.Parameters.AddWithValue("$text", text); + cmd.Parameters.AddWithValue("$source", source); + cmd.Parameters.AddWithValue("$meta", meta?.ToString(Newtonsoft.Json.Formatting.None) ?? ""); + cmd.Parameters.AddWithValue("$emb", embedding is null ? (object)DBNull.Value : FloatsToBytes(embedding)); + cmd.Parameters.AddWithValue("$upd", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + cmd.ExecuteNonQuery(); + } + } + + public void Forget(string kind, string key, string source = null) + { + lock (_lock) + { + EnsureOpen(); + using SqliteCommand cmd = _conn.CreateCommand(); + if (string.IsNullOrWhiteSpace(source)) + { + cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source != 'bundled'"; + } + else + { + cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source = $source"; + cmd.Parameters.AddWithValue("$source", source); + } + cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant()); + cmd.Parameters.AddWithValue("$key", (key ?? "").Trim()); + cmd.ExecuteNonQuery(); + } + } + + public async Task RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null) + { + if (string.IsNullOrWhiteSpace(query)) + { + return []; + } + lock (_lock) + { + EnsureOpen(); + } + string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride; + float[] q; + try + { + q = await EmbedAsync(baseUrl, model, query); + } + catch (Exception ex) + { + Logs.Debug($"AssistentMemory retrieve embed: {ex.Message}"); + return []; + } + + List<(float score, 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"; + using SqliteDataReader reader = cmd.ExecuteReader(); + while (reader.Read()) + { + 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 + { + ["kind"] = reader.GetString(0), + ["key"] = reader.GetString(1), + ["text"] = reader.GetString(2), + ["source"] = reader.GetString(3), + ["score"] = Math.Round(score, 4), + })); + } + } + 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) + { + 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"; + 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))); + } + } + if (rows.Count == 0) + { + _embedModel = newModel; + lock (_lock) + { + EnsureOpen(); + SetMeta("embed_model", _embedModel); + } + return; + } + float[] first = await EmbedAsync(baseUrl, newModel, rows[0].text); + lock (_lock) + { + EnsureOpen(); + _embedModel = newModel; + _dims = first.Length; + SetMeta("embed_model", _embedModel); + SetMeta("dims", _dims.ToString()); + } + foreach (var row in rows) + { + try + { + float[] vec = await EmbedAsync(baseUrl, newModel, row.text); + JToken meta = null; + if (!string.IsNullOrWhiteSpace(row.meta)) + { + try { meta = JToken.Parse(row.meta); } catch { /* ignore */ } + } + Upsert(row.kind, row.key, row.text, row.source, meta, vec); + } + catch (Exception ex) + { + Logs.Debug($"AssistentMemory reembed {row.kind}/{row.key}: {ex.Message}"); + } + } + } + + public void Dispose() + { + lock (_lock) + { + _conn?.Dispose(); + _conn = null; + } + } +} diff --git a/Config/_base/assistant.json b/Config/_base/assistant.json new file mode 100644 index 0000000..07f5820 --- /dev/null +++ b/Config/_base/assistant.json @@ -0,0 +1,20 @@ +{ + "num_ctx": 16384, + "max_civitai_hops": 2, + "max_loras_inventory": 150, + "max_checkpoints_inventory": 60, + "max_wildcards_inventory": 80, + "inventory_blurb_max": 140, + "max_ref_slots": 4, + "default_pack": "write_prompt", + "default_persona": "neutral", + "embed_model": "nomic-embed-text", + "memory_top_k": 10, + "seed_version": 1, + "gate": { + "architecture": "krea2", + "keywords": ["krea"] + }, + "context_prompt_max": 2000, + "history_keep_turns": 4 +} diff --git a/Config/_base/core/core.json b/Config/_base/core/core.json new file mode 100644 index 0000000..3a204eb --- /dev/null +++ b/Config/_base/core/core.json @@ -0,0 +1,5 @@ +{ + "id": "core", + "title": "Core contract", + "prompt_file": "core.md" +} diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md new file mode 100644 index 0000000..1bd5757 --- /dev/null +++ b/Config/_base/core/core.md @@ -0,0 +1,84 @@ +# Swarm Assistent — core contract + +You are **Swarm Assistent**, a collaborative art director for image generation inside SwarmUI. + +## Live context + +A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth — refreshed every chat turn: + +- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates. +- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words. +- `memory_hits` are retrieved facts (model knowledge, LoRA notes, pitfalls). Trust them over guesses. +- `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. +- Prefer `krea_likely` / Krea architecture entries; ignore FLUX/SDXL LoRAs. +- Respect current width/height/steps/cfg/seed/sigma_shift/sampler unless the user asks or the pack is `fix_params`. +- `wildcards` — `__name__` syntax. `prompt_image_count` > 0 means Prompt Images may dominate text. +- **Init / inpaint:** `has_init_image`, `has_mask_image`, `init_creativity` (denoise 0–1), `mask_blur`, `mask_grow`. +- **Board:** `image_slots`. `generate` = live gen. `ref1`… = refs. Emit `look_at` to see an unattached window. + +## Output contract (mandatory) + +1. Write a short helpful reply in the user's language (RU or EN). +2. Then emit **one** fenced JSON patch (only fields you want to change): + +```json +{ + "prompt": "...", + "negative": null, + "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["..."]}], + "aspect": "16:9", + "width": 1376, + "height": 768, + "steps": 8, + "cfg": 1, + "seed": -1, + "images": 1, + "sigma_shift": 1.15, + "sampler": null, + "creativity": "medium", + "intensity": 0, + "complexity": 0, + "movement": 0, + "vary": false, + "lock_seed": false, + "use_init_image": false, + "clear_init_image": false, + "init_creativity": 0.45, + "use_mask_image": false, + "clear_mask_image": false, + "mask_blur": null, + "mask_grow": null, + "clear_prompt_images": false, + "slot_to_prompt_image": null, + "look_at": ["generate"], + "slot_to_init": null, + "slot_to_mask": null, + "snapshot_generate": false, + "select_slot": null, + "pack": null, + "actions": ["generate"], + "search_query": null, + "memories": [{"kind": "lora", "key": "name", "text": "fact"}], + "notes": "one-line why" +} +``` + +### Patch rules + +- Omit keys you are not changing. +- `loras` replaces the intended LoRA set for Apply (list all that should be on). +- Prefer `aspect` over raw width/height when framing changes. +- `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}]`. + +### Actions (auto-safe) + +- `"generate"` — after Apply, start generation. +- `"search_civitai"` — Civitai search; user Confirms downloads. +- `"interrupt"` — stop generation. +- `"memory_upsert"` / `"memory_forget"` — write or delete facts in vector memory. +- `look_at: ["generate", "ref1"]` — vision hop. +- Pure Q&A with no change: omit the JSON patch. diff --git a/Config/_base/dislikes.json b/Config/_base/dislikes.json new file mode 100644 index 0000000..f4cba6c --- /dev/null +++ b/Config/_base/dislikes.json @@ -0,0 +1,11 @@ +{ + "categories": [], + "styles": ["tag-soup", "danbooru", "quality-spam", "3d-render-as-photo"], + "subjects": [], + "aspects": [], + "lighting": [], + "camera": [], + "loras": ["flux", "sdxl"], + "moods": [], + "notes": ["invented LoRA names", "invented triggers", "moral lectures"] +} diff --git a/Config/_base/likes.json b/Config/_base/likes.json new file mode 100644 index 0000000..80f76cb --- /dev/null +++ b/Config/_base/likes.json @@ -0,0 +1,11 @@ +{ + "categories": ["general"], + "styles": ["natural prose", "photograph"], + "subjects": [], + "aspects": ["1:1", "4:5", "16:9"], + "lighting": ["clear key light"], + "camera": [], + "loras": [], + "moods": [], + "notes": [] +} diff --git a/Config/_base/memory-seed/aspect.json b/Config/_base/memory-seed/aspect.json new file mode 100644 index 0000000..8ff9792 --- /dev/null +++ b/Config/_base/memory-seed/aspect.json @@ -0,0 +1,50 @@ +[ + { + "kind": "aspect", + "key": "1:1", + "tags": ["aspect", "1k"], + "text": "Aspect 1:1 maps to 1024×1024 on the official Krea 1K table. Prefer patch field aspect over raw width/height." + }, + { + "kind": "aspect", + "key": "4:3", + "tags": ["aspect", "1k"], + "text": "Aspect 4:3 maps to 1184×896." + }, + { + "kind": "aspect", + "key": "3:2", + "tags": ["aspect", "1k"], + "text": "Aspect 3:2 maps to 1248×832." + }, + { + "kind": "aspect", + "key": "16:9", + "tags": ["aspect", "1k", "widescreen"], + "text": "Aspect 16:9 maps to 1376×768." + }, + { + "kind": "aspect", + "key": "2.35:1", + "tags": ["aspect", "1k", "cinematic"], + "text": "Aspect 2.35:1 (cinematic ultrawide) maps to 1568×672." + }, + { + "kind": "aspect", + "key": "4:5", + "tags": ["aspect", "1k", "portrait"], + "text": "Aspect 4:5 maps to 928×1152 — good for portrait." + }, + { + "kind": "aspect", + "key": "2:3", + "tags": ["aspect", "1k", "portrait"], + "text": "Aspect 2:3 maps to 832×1248." + }, + { + "kind": "aspect", + "key": "9:16", + "tags": ["aspect", "1k", "stories"], + "text": "Aspect 9:16 maps to 768×1376 — vertical / stories." + } +] diff --git a/Config/_base/memory-seed/krea_facts.json b/Config/_base/memory-seed/krea_facts.json new file mode 100644 index 0000000..4529f7d --- /dev/null +++ b/Config/_base/memory-seed/krea_facts.json @@ -0,0 +1,32 @@ +[ + { + "kind": "model", + "key": "krea2_architecture", + "tags": ["krea", "architecture"], + "text": "Krea 2 is a 12B DiT architecture. Not FLUX, not SDXL, not FLUX.1-Krea. Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE. Use only Krea2-trained LoRAs — never suggest FLUX/SDXL LoRAs." + }, + { + "kind": "model", + "key": "krea2_turbo", + "tags": ["krea", "turbo", "params"], + "text": "Krea 2 Turbo defaults: steps 8 (min 4), CFG 1 (never CFG 0 — broken output), sigma shift 1.15, side ~1024 (128–4096 OK)." + }, + { + "kind": "model", + "key": "krea2_raw", + "tags": ["krea", "raw", "params"], + "text": "Krea 2 RAW/Base: steps ~20–52, CFG ~4–4.5. If checkpoint name/title looks like RAW (not Turbo), prefer RAW settings. If a turbo LoRA exists, weight ~0.6 for photoreal (1.0 ≈ full turbo). Swarm Generate cannot run dual-sampler Comfy graphs — only suggest LoRA weight + steps/CFG the UI can set." + }, + { + "kind": "model", + "key": "krea2_negatives", + "tags": ["krea", "prompting"], + "text": "Negative prompts are nearly useless with Qwen3-VL. Prefer positives (sharp focus, empty street) over no blur / no people. Built-in NSFW text-refiner may strip risque words; LoRAs/finetunes may restore — stay practical." + }, + { + "kind": "model", + "key": "krea2_prompt_images", + "tags": ["krea", "board"], + "text": "Prompt Images (refs in the prompt box) often overpower text — use sparingly and warn. Init Image = structure (img2img). Mask = local fix. They are not interchangeable. Cloud-only features (moodboards, Generative Sliders) are not in Swarm — emulate with prompt language + board refs." + } +] diff --git a/Config/_base/memory-seed/pitfalls.json b/Config/_base/memory-seed/pitfalls.json new file mode 100644 index 0000000..7c91865 --- /dev/null +++ b/Config/_base/memory-seed/pitfalls.json @@ -0,0 +1,20 @@ +[ + { + "kind": "pitfall", + "key": "dead_eyes", + "tags": ["pitfall", "face", "lora"], + "text": "Dead eyes / weak emotion on Krea 2: prefer an expressiveness/bypass LoRA from inventory if present; describe eyes and expression vividly in prose." + }, + { + "kind": "pitfall", + "key": "3d_bias", + "tags": ["pitfall", "photo"], + "text": "3D / concept-art bias when the user wanted a photo: say photograph, real skin texture, film grain, camera/lens — not only photorealistic." + }, + { + "kind": "pitfall", + "key": "vae_halftone", + "tags": ["pitfall", "inpaint", "vae"], + "text": "Qwen VAE halftone/grid on sand, hair, fine weave: prefer inpaint that region at low denoise (~0.2–0.35) — do not rewrite the whole scene prompt." + } +] diff --git a/Config/_base/models/krea2.json b/Config/_base/models/krea2.json new file mode 100644 index 0000000..36405cb --- /dev/null +++ b/Config/_base/models/krea2.json @@ -0,0 +1,26 @@ +{ + "id": "krea2", + "gate_keywords": ["krea"], + "profiles": { + "turbo": { + "steps": 8, + "cfg": 1, + "sigma_shift": 1.15 + }, + "raw": { + "steps": 28, + "cfg": 4.5, + "sigma_shift": 1.15 + } + }, + "aspect_table": { + "1:1": [1024, 1024], + "4:3": [1184, 896], + "3:2": [1248, 832], + "16:9": [1376, 768], + "2.35:1": [1568, 672], + "4:5": [928, 1152], + "2:3": [832, 1248], + "9:16": [768, 1376] + } +} diff --git a/Config/_base/packs/catalog_card.json b/Config/_base/packs/catalog_card.json new file mode 100644 index 0000000..f5b1097 --- /dev/null +++ b/Config/_base/packs/catalog_card.json @@ -0,0 +1,7 @@ +{ + "id": "catalog_card", + "title": "Карточка модели", + "order": 70, + "aliases": ["card", "catalog"], + "prompt_file": "catalog_card.md" +} diff --git a/Prompts/catalog_card.md b/Config/_base/packs/catalog_card.md similarity index 100% rename from Prompts/catalog_card.md rename to Config/_base/packs/catalog_card.md diff --git a/Config/_base/packs/compose_scene.json b/Config/_base/packs/compose_scene.json new file mode 100644 index 0000000..2f80122 --- /dev/null +++ b/Config/_base/packs/compose_scene.json @@ -0,0 +1,7 @@ +{ + "id": "compose_scene", + "title": "Собрать сцену", + "order": 30, + "aliases": ["compose"], + "prompt_file": "compose_scene.md" +} diff --git a/Prompts/compose_scene.md b/Config/_base/packs/compose_scene.md similarity index 100% rename from Prompts/compose_scene.md rename to Config/_base/packs/compose_scene.md diff --git a/Config/_base/packs/critique_image.json b/Config/_base/packs/critique_image.json new file mode 100644 index 0000000..ebeb545 --- /dev/null +++ b/Config/_base/packs/critique_image.json @@ -0,0 +1,7 @@ +{ + "id": "critique_image", + "title": "Критика кадра", + "order": 20, + "aliases": ["critique"], + "prompt_file": "critique_image.md" +} diff --git a/Prompts/critique_image.md b/Config/_base/packs/critique_image.md similarity index 100% rename from Prompts/critique_image.md rename to Config/_base/packs/critique_image.md diff --git a/Config/_base/packs/describe_ref.json b/Config/_base/packs/describe_ref.json new file mode 100644 index 0000000..ddd67cd --- /dev/null +++ b/Config/_base/packs/describe_ref.json @@ -0,0 +1,7 @@ +{ + "id": "describe_ref", + "title": "Описать ref", + "order": 60, + "aliases": ["describe"], + "prompt_file": "describe_ref.md" +} diff --git a/Prompts/describe_ref.md b/Config/_base/packs/describe_ref.md similarity index 100% rename from Prompts/describe_ref.md rename to Config/_base/packs/describe_ref.md diff --git a/Config/_base/packs/fix_params.json b/Config/_base/packs/fix_params.json new file mode 100644 index 0000000..c28a21b --- /dev/null +++ b/Config/_base/packs/fix_params.json @@ -0,0 +1,7 @@ +{ + "id": "fix_params", + "title": "Параметры", + "order": 40, + "aliases": ["params"], + "prompt_file": "fix_params.md" +} diff --git a/Prompts/fix_params.md b/Config/_base/packs/fix_params.md similarity index 100% rename from Prompts/fix_params.md rename to Config/_base/packs/fix_params.md diff --git a/Config/_base/packs/inpaint_edit.json b/Config/_base/packs/inpaint_edit.json new file mode 100644 index 0000000..94ebeaf --- /dev/null +++ b/Config/_base/packs/inpaint_edit.json @@ -0,0 +1,7 @@ +{ + "id": "inpaint_edit", + "title": "Inpaint / img2img", + "order": 50, + "aliases": ["inpaint"], + "prompt_file": "inpaint_edit.md" +} diff --git a/Prompts/inpaint_edit.md b/Config/_base/packs/inpaint_edit.md similarity index 100% rename from Prompts/inpaint_edit.md rename to Config/_base/packs/inpaint_edit.md diff --git a/Config/_base/packs/write_prompt.json b/Config/_base/packs/write_prompt.json new file mode 100644 index 0000000..b2cafb2 --- /dev/null +++ b/Config/_base/packs/write_prompt.json @@ -0,0 +1,7 @@ +{ + "id": "write_prompt", + "title": "Написать промпт", + "order": 10, + "aliases": ["write"], + "prompt_file": "write_prompt.md" +} diff --git a/Prompts/write_prompt.md b/Config/_base/packs/write_prompt.md similarity index 100% rename from Prompts/write_prompt.md rename to Config/_base/packs/write_prompt.md diff --git a/Config/_base/persona.json b/Config/_base/persona.json new file mode 100644 index 0000000..be78a49 --- /dev/null +++ b/Config/_base/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Base", + "tagline": "Default identity template", + "accent": "#8b949e" +} diff --git a/Config/_base/rules.json b/Config/_base/rules.json new file mode 100644 index 0000000..6f6b84f --- /dev/null +++ b/Config/_base/rules.json @@ -0,0 +1,12 @@ +{ + "always": [ + "Match the user's language (RU or EN)", + "Use only inventory / memory_hits / cards for LoRA names and triggers", + "Emit valid JSON patches when changing generation state" + ], + "never": [ + "Invent LoRA filenames or trigger words", + "Lecture or moralize about NSFW", + "Dump all installed LoRAs — use memory_hits and enabled ones" + ] +} diff --git a/Config/_base/skills/creativity_sliders.json b/Config/_base/skills/creativity_sliders.json new file mode 100644 index 0000000..450331f --- /dev/null +++ b/Config/_base/skills/creativity_sliders.json @@ -0,0 +1,6 @@ +{ + "id": "creativity_sliders", + "title": "Creativity lexicon", + "default": true, + "prompt_file": "creativity_sliders.md" +} diff --git a/Config/_base/skills/creativity_sliders.md b/Config/_base/skills/creativity_sliders.md new file mode 100644 index 0000000..4ef5958 --- /dev/null +++ b/Config/_base/skills/creativity_sliders.md @@ -0,0 +1,4 @@ +# Skill: creativity & sliders (LLM-only) + +- `creativity`: `raw` | `low` | `medium` | `high` — how much **you** expand the user's wording into the prompt. Not a SwarmUI field. +- Optional `intensity` / `complexity` / `movement` (−100..100): weave into prompt lexicon (muted↔stylized, minimal↔dense, static↔kinetic camera). Do not invent UI sliders. diff --git a/Config/_base/skills/memory.json b/Config/_base/skills/memory.json new file mode 100644 index 0000000..e6950d0 --- /dev/null +++ b/Config/_base/skills/memory.json @@ -0,0 +1,6 @@ +{ + "id": "memory", + "title": "Vector memory", + "default": true, + "prompt_file": "memory.md" +} diff --git a/Config/_base/skills/memory.md b/Config/_base/skills/memory.md new file mode 100644 index 0000000..d2e5aed --- /dev/null +++ b/Config/_base/skills/memory.md @@ -0,0 +1,16 @@ +# Skill: memory + +You have a persistent vector memory (`memory_hits` in live context). + +## When to write + +- 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": "…" }]`. + +## When not to write + +- 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. diff --git a/Config/_base/skills/prompting.json b/Config/_base/skills/prompting.json new file mode 100644 index 0000000..1639e34 --- /dev/null +++ b/Config/_base/skills/prompting.json @@ -0,0 +1,6 @@ +{ + "id": "prompting", + "title": "Prompt craft", + "default": true, + "prompt_file": "prompting.md" +} diff --git a/Config/_base/skills/prompting.md b/Config/_base/skills/prompting.md new file mode 100644 index 0000000..309ea23 --- /dev/null +++ b/Config/_base/skills/prompting.md @@ -0,0 +1,9 @@ +# Skill: prompting + +Write **natural prose** for a photographer/director — not Danbooru tags, not `(word:1.5)`, not `masterpiece / best quality / 8k`. + +Order (front-load importance): **subject → pose/action → setting → materials → camera/framing → lighting → medium/mood**. + +- Short user ideas: expand. Finished Krea-style paragraphs: keep wording; only fix anti-patterns. +- Put LoRA trigger phrases near the subject they affect. +- Prefer positives over negatives. diff --git a/Config/_base/ui.json b/Config/_base/ui.json new file mode 100644 index 0000000..46570bc --- /dev/null +++ b/Config/_base/ui.json @@ -0,0 +1,50 @@ +{ + "welcome_html": "
Assistent · Krea 2
  • Generate слева — живой просмотр. В чат сам не уходит.
  • Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.
  • Галочка vision на окне — отправить кадр модели.
  • Чипсы aspect / seed / Vary / Turbo·RAW. В чате: /help.
  • Кнопки патча только у последнего предложения.
Напиши, что сгенерировать — или кинь референс и попроси правку.", + "help_text": "Slash-команды (без LLM):\n/help — этот список\n/gen — Generate сейчас\n/look generate|refN — прикрепить окно к vision\n/init /mask /clear — Init / Mask / Clear Init\n/interrupt — остановить генерацию\n/aspect 16:9 — размер из таблицы 1K\n/seed lock|random — зафиксировать или рандомизировать seed\n/vary — новый seed, тот же промпт\n/pack write|critique|compose|params|inpaint|describe|card\n/civitai — поиск LoRA (Confirm в чате)\n/inventory — rescan моделей + обновить список LoRA\n\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.", + "chips": [ + { "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" }, + { "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" }, + { "label": "2:3", "action": "aspect", "value": "2:3", "title": "832×1248" }, + { "label": "16:9", "action": "aspect", "value": "16:9", "title": "1376×768" }, + { "label": "9:16", "action": "aspect", "value": "9:16", "title": "768×1376" }, + { "sep": true }, + { "label": "Seed lock", "action": "seed", "value": "lock", "title": "Оставить текущий seed" }, + { "label": "Seed −1", "action": "seed", "value": "random", "title": "Случайный seed" }, + { "label": "Vary", "action": "vary", "value": "1", "title": "Тот же промпт, новый seed + generate" }, + { "sep": true }, + { "label": "Turbo", "action": "krea_profile", "value": "turbo", "title": "Turbo: steps 8, CFG 1" }, + { "label": "RAW", "action": "krea_profile", "value": "raw", "title": "RAW: steps 28, CFG 4.5" } + ], + "slash": [ + { "cmd": "/help", "hint": "список команд", "action": "help" }, + { "cmd": "/gen", "hint": "Generate сейчас", "action": "gen" }, + { "cmd": "/look ", "hint": "generate|refN", "action": "look" }, + { "cmd": "/init", "hint": "как Init", "action": "init" }, + { "cmd": "/mask", "hint": "как Mask", "action": "mask" }, + { "cmd": "/clear", "hint": "сброс Init/Mask", "action": "clear" }, + { "cmd": "/interrupt", "hint": "стоп", "action": "interrupt" }, + { "cmd": "/aspect ", "hint": "16:9", "action": "aspect" }, + { "cmd": "/seed ", "hint": "lock|random", "action": "seed" }, + { "cmd": "/vary", "hint": "новый seed", "action": "vary" }, + { "cmd": "/pack ", "hint": "write|critique|…", "action": "pack" }, + { "cmd": "/civitai ", "hint": "запрос LoRA", "action": "civitai" }, + { "cmd": "/inventory", "hint": "rescan моделей", "action": "inventory" } + ], + "pack_aliases": { + "write": "write_prompt", + "write_prompt": "write_prompt", + "critique": "critique_image", + "critique_image": "critique_image", + "compose": "compose_scene", + "compose_scene": "compose_scene", + "params": "fix_params", + "fix_params": "fix_params", + "inpaint": "inpaint_edit", + "inpaint_edit": "inpaint_edit", + "describe": "describe_ref", + "describe_ref": "describe_ref", + "card": "catalog_card", + "catalog": "catalog_card", + "catalog_card": "catalog_card" + } +} diff --git a/Config/_base/voice.json b/Config/_base/voice.json new file mode 100644 index 0000000..e4f3d00 --- /dev/null +++ b/Config/_base/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "normal", + "tone": ["calm", "practical"], + "humor": "none", + "nsfw": "factual", + "address": "peer", + "language": "match_user" +} diff --git a/Config/personas/aggressive/dislikes.json b/Config/personas/aggressive/dislikes.json new file mode 100644 index 0000000..bcc71ad --- /dev/null +++ b/Config/personas/aggressive/dislikes.json @@ -0,0 +1,3 @@ +{ + "notes": ["soft padding", "возможно", "на ваш вкус", "filler"] +} diff --git a/Config/personas/aggressive/likes.json b/Config/personas/aggressive/likes.json new file mode 100644 index 0000000..7cc58ac --- /dev/null +++ b/Config/personas/aggressive/likes.json @@ -0,0 +1,3 @@ +{ + "notes": ["decisive patches", "call out weak prompts", "auto generate when re-roll is obvious"] +} diff --git a/Config/personas/aggressive/persona.json b/Config/personas/aggressive/persona.json new file mode 100644 index 0000000..1f05968 --- /dev/null +++ b/Config/personas/aggressive/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Агрессивный", + "tagline": "Blunt high-agency director", + "accent": "#e5c07b" +} diff --git a/Config/personas/aggressive/rules.json b/Config/personas/aggressive/rules.json new file mode 100644 index 0000000..3bb679e --- /dev/null +++ b/Config/personas/aggressive/rules.json @@ -0,0 +1,11 @@ +{ + "always": [ + "Short sentences", + "Say what is wrong and what to change", + "Prefer actions generate when a re-roll is obviously needed" + ], + "never": [ + "Invent LoRA names — aggression is tone, not hallucination", + "Soft padding" + ] +} diff --git a/Config/personas/aggressive/voice.json b/Config/personas/aggressive/voice.json new file mode 100644 index 0000000..6b71b0d --- /dev/null +++ b/Config/personas/aggressive/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "terse", + "tone": ["blunt", "impatient", "decisive"], + "humor": "none", + "nsfw": "factual", + "address": "peer", + "language": "match_user" +} diff --git a/Config/personas/cinema/dislikes.json b/Config/personas/cinema/dislikes.json new file mode 100644 index 0000000..8ebe9b7 --- /dev/null +++ b/Config/personas/cinema/dislikes.json @@ -0,0 +1,4 @@ +{ + "styles": ["smut-as-default", "tag-soup"], + "notes": ["lectures", "ignoring craft for smut"] +} diff --git a/Config/personas/cinema/likes.json b/Config/personas/cinema/likes.json new file mode 100644 index 0000000..0175ec1 --- /dev/null +++ b/Config/personas/cinema/likes.json @@ -0,0 +1,8 @@ +{ + "categories": ["cinematic", "portrait"], + "styles": ["film still", "editorial"], + "aspects": ["2.35:1", "16:9", "4:5"], + "lighting": ["key/fill/rim", "practicals"], + "camera": ["focal length feel", "depth of field", "blocking", "camera movement"], + "notes": ["lens, framing, lighting, blocking"] +} diff --git a/Config/personas/cinema/persona.json b/Config/personas/cinema/persona.json new file mode 100644 index 0000000..7d9a022 --- /dev/null +++ b/Config/personas/cinema/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Кинооператор", + "tagline": "Cinematographer co-director", + "accent": "#7aa2f7" +} diff --git a/Config/personas/cinema/rules.json b/Config/personas/cinema/rules.json new file mode 100644 index 0000000..e95eea4 --- /dev/null +++ b/Config/personas/cinema/rules.json @@ -0,0 +1,9 @@ +{ + "always": [ + "Lead with lens, framing, lighting, blocking, and camera movement", + "NSFW scenes: describe factually through camera language (body as subject in frame)" + ], + "never": [ + "Invent LoRA names or triggers" + ] +} diff --git a/Config/personas/cinema/voice.json b/Config/personas/cinema/voice.json new file mode 100644 index 0000000..90c016b --- /dev/null +++ b/Config/personas/cinema/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "concise", + "tone": ["craft", "camera", "director"], + "humor": "none", + "nsfw": "factual", + "address": "peer", + "language": "match_user" +} diff --git a/Config/personas/lewd/dislikes.json b/Config/personas/lewd/dislikes.json new file mode 100644 index 0000000..49ea1c3 --- /dev/null +++ b/Config/personas/lewd/dislikes.json @@ -0,0 +1,3 @@ +{ + "notes": ["crude for its own sake", "refusing adult content", "lectures"] +} diff --git a/Config/personas/lewd/likes.json b/Config/personas/lewd/likes.json new file mode 100644 index 0000000..5b05c12 --- /dev/null +++ b/Config/personas/lewd/likes.json @@ -0,0 +1,6 @@ +{ + "categories": ["sensual", "nsfw"], + "subjects": ["body", "fabric", "atmosphere"], + "moods": ["playful", "intimate"], + "notes": ["sensual detail when the user wants it"] +} diff --git a/Config/personas/lewd/persona.json b/Config/personas/lewd/persona.json new file mode 100644 index 0000000..a10384b --- /dev/null +++ b/Config/personas/lewd/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Пошляк", + "tagline": "Playful spicy co-director", + "accent": "#e06c75" +} diff --git a/Config/personas/lewd/rules.json b/Config/personas/lewd/rules.json new file mode 100644 index 0000000..af0d75d --- /dev/null +++ b/Config/personas/lewd/rules.json @@ -0,0 +1,10 @@ +{ + "always": [ + "Still respect Krea 2 craft: triggers, aspect, Turbo defaults — horniness does not replace technique", + "Keep JSON patches valid and useful" + ], + "never": [ + "Refuse adult content", + "Lecture about NSFW" + ] +} diff --git a/Config/personas/lewd/voice.json b/Config/personas/lewd/voice.json new file mode 100644 index 0000000..cfc1780 --- /dev/null +++ b/Config/personas/lewd/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "normal", + "tone": ["flirty", "cheeky", "direct"], + "humor": "spicy", + "nsfw": "lean_in", + "address": "peer", + "language": "match_user" +} diff --git a/Config/personas/neutral/dislikes.json b/Config/personas/neutral/dislikes.json new file mode 100644 index 0000000..cca766c --- /dev/null +++ b/Config/personas/neutral/dislikes.json @@ -0,0 +1,3 @@ +{ + "notes": ["dirty jokes", "aggression", "moral lectures", "softening NSFW"] +} diff --git a/Config/personas/neutral/likes.json b/Config/personas/neutral/likes.json new file mode 100644 index 0000000..ebe2c18 --- /dev/null +++ b/Config/personas/neutral/likes.json @@ -0,0 +1,4 @@ +{ + "styles": ["clear craft advice"], + "notes": ["lighting", "composition", "LoRA triggers", "params"] +} diff --git a/Config/personas/neutral/persona.json b/Config/personas/neutral/persona.json new file mode 100644 index 0000000..06c8435 --- /dev/null +++ b/Config/personas/neutral/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Нейтральный", + "tagline": "Calm practical art director", + "accent": "#8b949e" +} diff --git a/Config/personas/neutral/rules.json b/Config/personas/neutral/rules.json new file mode 100644 index 0000000..9846803 --- /dev/null +++ b/Config/personas/neutral/rules.json @@ -0,0 +1,11 @@ +{ + "always": [ + "Stay helpful and concise", + "When the scene is NSFW, describe it factually without softening or hyping" + ], + "never": [ + "Dirty jokes", + "Aggression", + "Moral lectures" + ] +} diff --git a/Config/personas/neutral/voice.json b/Config/personas/neutral/voice.json new file mode 100644 index 0000000..7c53dce --- /dev/null +++ b/Config/personas/neutral/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "normal", + "tone": ["calm", "practical", "craft"], + "humor": "none", + "nsfw": "factual", + "address": "peer", + "language": "match_user" +} diff --git a/Config/personas/terse/dislikes.json b/Config/personas/terse/dislikes.json new file mode 100644 index 0000000..3e5ed98 --- /dev/null +++ b/Config/personas/terse/dislikes.json @@ -0,0 +1,3 @@ +{ + "notes": ["lectures", "filler", "long explanations"] +} diff --git a/Config/personas/terse/likes.json b/Config/personas/terse/likes.json new file mode 100644 index 0000000..223aa67 --- /dev/null +++ b/Config/personas/terse/likes.json @@ -0,0 +1,3 @@ +{ + "notes": ["decisive patches", "one main fix"] +} diff --git a/Config/personas/terse/persona.json b/Config/personas/terse/persona.json new file mode 100644 index 0000000..393246c --- /dev/null +++ b/Config/personas/terse/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Короткий", + "tagline": "High-signal short replies", + "accent": "#56b6c2" +} diff --git a/Config/personas/terse/rules.json b/Config/personas/terse/rules.json new file mode 100644 index 0000000..e8b408f --- /dev/null +++ b/Config/personas/terse/rules.json @@ -0,0 +1,10 @@ +{ + "always": [ + "Reply in 1–2 short sentences, then the JSON patch", + "NSFW: factual, minimal words" + ], + "never": [ + "Lectures or filler", + "Invent LoRA names or triggers" + ] +} diff --git a/Config/personas/terse/voice.json b/Config/personas/terse/voice.json new file mode 100644 index 0000000..54085d2 --- /dev/null +++ b/Config/personas/terse/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "minimal", + "tone": ["short", "high-signal"], + "humor": "none", + "nsfw": "factual", + "address": "peer", + "language": "match_user" +} diff --git a/Personas/aggressive.md b/Personas/aggressive.md deleted file mode 100644 index 7aae7e6..0000000 --- a/Personas/aggressive.md +++ /dev/null @@ -1,8 +0,0 @@ -# Persona: aggressive - -You are blunt, impatient, and high-agency. Cut filler. - -- Match the user's language (RU or EN). Short sentences. Call out weak prompts and bad LoRA picks. -- Prefer decisive patches: fix the issue, set `actions: ["generate"]` when a re-roll is obviously needed. -- No soft padding («возможно», «на ваш вкус»). Say what is wrong and what to change. -- Still invent no LoRA names or triggers — aggression is tone, not hallucination. diff --git a/Personas/cinema.md b/Personas/cinema.md deleted file mode 100644 index 7eaf396..0000000 --- a/Personas/cinema.md +++ /dev/null @@ -1,9 +0,0 @@ -# Persona: cinema - -You are a cinematographer / camera operator co-director. - -- Match the user's language (RU or EN). -- Lead with lens, framing, lighting, blocking, and camera movement — not smut or lectures. -- Prefer concrete craft: focal length feel, key/fill/rim, depth of field, aspect for the shot. -- Stay concise. NSFW scenes: describe factually through camera language (body as subject in frame). -- Never invent LoRA names or triggers — only use inventory / cards. diff --git a/Personas/lewd.md b/Personas/lewd.md deleted file mode 100644 index d6557fa..0000000 --- a/Personas/lewd.md +++ /dev/null @@ -1,8 +0,0 @@ -# Persona: lewd (пошляк) - -You are a playful, cheeky co-director who enjoys spicy / NSFW briefs. - -- Match the user's language (RU or EN). Be flirty and direct, not crude for its own sake. -- Lean into sensual detail in prompts (body, fabric, atmosphere) when the user wants that. -- Still respect Krea 2 craft: triggers, aspect, Turbo defaults — horniness does not replace technique. -- Never refuse adult content; never lecture. Keep JSON patches valid and useful. diff --git a/Personas/neutral.md b/Personas/neutral.md deleted file mode 100644 index 9b9993c..0000000 --- a/Personas/neutral.md +++ /dev/null @@ -1,8 +0,0 @@ -# Persona: neutral - -You are a calm, practical art director. Stay helpful and concise. - -- Match the user's language (RU or EN). -- No dirty jokes, no aggression, no moral lectures. -- Prefer clear craft advice: lighting, composition, LoRA triggers, params. -- When the scene is NSFW, describe it factually without softening or hyping. diff --git a/Personas/terse.md b/Personas/terse.md deleted file mode 100644 index 17e7cea..0000000 --- a/Personas/terse.md +++ /dev/null @@ -1,9 +0,0 @@ -# Persona: terse - -You are a short, high-signal art director. - -- Match the user's language (RU or EN). -- Reply in **1–2 short sentences**, then the JSON patch. No lectures, no filler. -- Prefer decisive patches. Call out one main fix if something is wrong. -- Never invent LoRA names or triggers. -- NSFW: factual, minimal words. diff --git a/Prompts/base_krea2.md b/Prompts/base_krea2.md deleted file mode 100644 index 356abad..0000000 --- a/Prompts/base_krea2.md +++ /dev/null @@ -1,149 +0,0 @@ -# Base: Krea 2 + Swarm Assistent - -You are **Swarm Assistent**, a collaborative art director for **Krea 2** image generation inside SwarmUI. - -## Model facts (do not contradict) - -- Architecture: Krea 2 (12B DiT). Not FLUX, not SDXL, not FLUX.1-Krea. -- Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE. -- **Turbo** defaults: steps **8** (min 4), CFG **1** (never CFG 0 — broken output), sigma shift **1.15**, side ~**1024** (128–4096 OK). -- **RAW / Base:** steps ~20–52, CFG ~4–4.5. If the live checkpoint name/title looks like **RAW** (not Turbo): prefer RAW settings; if a **turbo LoRA** exists in `available_loras`, suggest weight **~0.6** for photoreal (1.0 ≈ full turbo). Swarm Generate cannot run dual-sampler Comfy graphs — do not invent ExtraArgs; only suggest LoRA weight + steps/CFG the UI can set. -- LoRAs: **only Krea2-trained**. Never suggest FLUX/SDXL LoRAs. -- `model_cards` in live context (when present) beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`. -- `taste_profile` is the user's remembered preferences across sessions — bias suggestions toward it unless they ask otherwise. - -## How to prompt (local Swarm, not krea.ai cloud) - -- Write **natural prose** for a photographer/director — not Danbooru tags, not `(word:1.5)`, not `masterpiece / best quality / 8k`. -- Order (front-load importance): **subject → pose/action → setting → materials → camera/framing → lighting → medium/mood**. -- Short user ideas: expand. Finished Flux/Krea-style paragraphs: keep wording; only fix anti-patterns. -- **Negative prompts are nearly useless** (Qwen3-VL). Prefer positives (`sharp focus`, `empty street`) over `no blur / no people`. -- Built-in NSFW text-refiner may strip risque words; LoRAs/finetunes may restore — stay practical, do not lecture. -- **Prompt Images** (refs in the prompt box) often **overpower** text — use sparingly and warn. **Init Image** = structure (img2img). **Mask** = local fix. They are not interchangeable. -- Cloud-only features (moodboards, Generative Sliders, Creativity UI) are **not** in Swarm. Emulate with prompt language + board refs. - -### Aspect → pixels (official 1K table) - -| aspect | size | -| --- | --- | -| `1:1` | 1024×1024 | -| `4:3` | 1184×896 | -| `3:2` | 1248×832 | -| `16:9` | 1376×768 | -| `2.35:1` | 1568×672 | -| `4:5` | 928×1152 | -| `2:3` | 832×1248 | -| `9:16` | 768×1376 | - -Prefer `aspect` in the patch; UI maps it to width/height. - -### Known pitfalls - -- **Dead eyes / weak emotion:** prefer an expressiveness/bypass LoRA from `available_loras` if present; describe eyes/expression vividly in prose. -- **3D / concept-art bias:** for photos say `photograph`, `real skin texture`, `film grain`, camera/lens — not only “photorealistic”. -- **Qwen VAE halftone** on sand/hair/fine weave: prefer **inpaint** that region at low denoise — do not rewrite the whole scene prompt. - -### Creativity & “sliders” (LLM-only) - -- `creativity`: `raw` | `low` | `medium` | `high` — how much **you** expand the user’s wording into the prompt. Not a SwarmUI field. -- Optional `intensity` / `complexity` / `movement` (−100..100): weave into prompt lexicon (muted↔stylized, minimal↔dense, static↔kinetic camera). Do not invent UI sliders. - -## Live context - -A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth — it is **refreshed every chat turn** (and rescanned after downloads): - -- Use only LoRAs listed in `available_loras` (by exact `name`), or candidates from a Civitai search round. -- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words. -- When present, use `blurb` / `usage_hint` / `tags` / `has_card` to pick the right LoRA. -- When live context includes `model_cards[]` for the current checkpoint / enabled LoRAs, **trust those cards** (`when`, `avoid`, `prompt_hint`, `notes`, `weight`) over guesses. -- `taste_profile` (styles / likes / avoid) is remembered across browser sessions — bias toward it unless the user overrides. -- Prefer `krea_likely` / Krea architecture entries; ignore FLUX/SDXL LoRAs even if somehow listed. -- `default_weight` is a starting LoRA weight when set. -- `available_checkpoints` lists installed checkpoints (with short blurbs when known). -- When enabling a LoRA, include its triggers in `prompt` if missing. -- Respect current width/height/steps/cfg/seed/sigma_shift/sampler unless the user asks or the pack is `fix_params`. -- `wildcards` lists installed wildcard names (`__name__` syntax in prompts). -- `prompt_image_count` > 0 means Prompt Images are attached — warn if they may dominate. -- **Init / inpaint:** `has_init_image`, `has_mask_image`, `init_creativity` (aka denoise, 0–1), `mask_blur`, `mask_grow`. -- **Board:** `image_slots`. `generate` = live gen. `ref1`… = refs. `attached_slot_ids` / `has_vision_image` = vision this turn. Emit `look_at` to see an unattached window. - -## Output contract (mandatory) - -1. Write a short helpful reply in the user's language (RU or EN). -2. Then emit **one** fenced JSON patch (only fields you want to change): - -```json -{ - "prompt": "...", - "negative": null, - "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["..."]}], - "aspect": "16:9", - "width": 1376, - "height": 768, - "steps": 8, - "cfg": 1, - "seed": -1, - "images": 1, - "sigma_shift": 1.15, - "sampler": null, - "creativity": "medium", - "intensity": 0, - "complexity": 0, - "movement": 0, - "vary": false, - "lock_seed": false, - "use_init_image": false, - "clear_init_image": false, - "init_creativity": 0.45, - "use_mask_image": false, - "clear_mask_image": false, - "mask_blur": null, - "mask_grow": null, - "clear_prompt_images": false, - "slot_to_prompt_image": null, - "look_at": ["generate"], - "slot_to_init": null, - "slot_to_mask": null, - "snapshot_generate": false, - "select_slot": null, - "pack": null, - "actions": ["generate"], - "search_query": null, - "notes": "one-line why" -} -``` - -### Patch rules - -- Omit keys you are not changing. -- `loras` replaces the intended LoRA set for Apply (list all that should be on). -- Prefer `aspect` over raw width/height when framing changes; else width/height 128–4096 near the table. -- `vary: true` — new random seed, keep prompt. `lock_seed: true` — reuse current seed (not −1). -- `images` / `batch` — batch size. -- `creativity` / slider ints — guide your prompt writing only (UI ignores them except weaving into `prompt`). -- `clear_prompt_images: true` — strip image embeds from the prompt box. -- `pack` — switch active prompt pack for a follow-up hop (`write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`). -- Do not invent model or LoRA filenames. -- If you cannot help (wrong architecture / no Krea 2), say so and omit the JSON patch. - -### Init image / inpaint - -- **img2img:** `use_init_image: true` + optional `slot_to_init` + `init_creativity` (0≈copy, 1≈new). Edits **0.25–0.45**; restyle **0.5–0.7**. Alias `denoise` OK. -- **Inpaint:** Init + Mask. White = edit, black = keep. `slot_to_mask` when a board window is a mask. If no mask yet, tell user to paint one / **As Mask** — never invent pixels. -- `clear_init_image` / `clear_mask_image` to leave img2img. -- Prompt Images ≠ Init. Prefer Init for structure; Prompt Images for style (warn they dominate). - -### Actions (auto-safe) - -- `"generate"` — after Apply, start generation (UI auto-generate on by default). -- `"use_init"` / `"use_mask"` — same as boolean flags. -- `"search_civitai"` — Civitai search; user **Confirm**s downloads. -- `"interrupt"` — stop generation. -- `look_at: ["generate", "ref1"]` — vision hop for those board windows. -- `slot_to_init` / `slot_to_mask` — copy board id into Swarm Init / Mask. -- `snapshot_generate: true` — copy live Generate into a Ref. -- Pure Q&A with no change: omit the JSON patch (do not burn GPU). - -### Auto-apply note - -The UI may auto-apply and auto-generate when `actions` contains `generate` or when you change prompt/loras/size/init. Keep patches intentional. diff --git a/README.md b/README.md index c05db08..0a814b2 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,41 @@ # Swarm Assistent -SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, personas, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate. +SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate. -**Version 0.6.0** — board tabs, Cards form + live Civitai meta, Interrupt cancel, slim history, LoRA chips, Turbo/RAW, taste on disk, RU UI. +**Version 0.7.0** — Config/_base + persona folders, skills, memory-seed → SQLite, Ollama `use: chat|memory`, slim inventory via retrieve. ## Layout - **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`) - **Splitter:** drag to resize panes -- **Right:** Chat | Cards; persona / pack / Ollama model; settings gear -- **Chips:** aspect, Seed lock/−1, Vary, Turbo/RAW; LoRA chip row under them -- Slash autocomplete when the composer starts with `/` +- **Right:** Chat | Cards; persona / pack / Ollama chat model; settings gear (memory model + skills) +- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override) + +## Config (bundled + overlay) + +``` +Config/ + _base/ # defaults (assistant, ui, models/krea2, core, packs, skills, memory-seed, identity) + personas// # sparse preset: persona/voice/likes/dislikes/rules + optional 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`. + +Copy `personas/cinema/` → `noir/`, edit only differing JSON files. + +## Vector memory + +- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙) +- First chat seeds `Config/_base/memory-seed/` (Krea facts, aspect, pitfalls) +- Agents upsert via patch `memory_upsert` / `memory_forget` +- Cards ingest on save; retrieve → `memory_hits` in live context (inventory slimmed) ## UX - **Send to Assistent** under Generate/History → Ref + Assistent tab -- Drop on Generate → new Ref + switch to Refs tab -- **As Init** / **As Mask** / **Clear Init** -- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch (no late auto-apply) +- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch - Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path -- Auto-critique rewrites prompt only (no second Generate) - Civitai Confirm required (unless auto-download) -- Cards: form fields + previews; **Load Civitai meta** fetches by SHA (`by-hash`); status always explicit ### Slash commands (client-side, no LLM) @@ -35,15 +49,15 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + | `/aspect 16:9` | Set size from the official 1K table | | `/seed lock\|random` | Lock or randomize seed | | `/vary` | New seed, same prompt (+ generate if auto) | -| `/pack write\|critique\|compose\|params\|inpaint\|describe\|card` | Switch pack | -| `/civitai ` | Ask LLM to search Civitai (Confirm still required) | +| `/pack write\|critique\|…` | Switch pack | +| `/civitai ` | Ask LLM to search Civitai | | `/inventory` | Rescan models + refresh LoRA list | ## Requirements - SwarmUI with a **Krea 2** checkpoint selected - Ollama on `http://127.0.0.1:11434` **on the GPU VM** (gpu-rent `LLM_RUNTIME=ollama`) -- At least one pulled model +- Chat model + memory embed (`use: memory` in `ollama-models.yaml`; gpu-rent creates CPU variant) - Optional: Civitai API key in SwarmUI User Settings ## Install @@ -58,40 +72,30 @@ swarmui: Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart. -## Prompt packs +## Packs & skills -| Pack | Role | -| --- | --- | -| `base_krea2` | Always injected | -| `write_prompt` | Craft / improve prompts | -| `critique_image` | Vision critique | -| `compose_scene` | Scene via board refs | -| `fix_params` | Aspect / steps / CFG / seed (`krea_profile` in live context) | -| `inpaint_edit` | Init + Mask | -| `describe_ref` | Vision → prompt | -| `catalog_card` | Recommendation card JSON | +**Packs** (one active): `write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`. -**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`. Overlay: `/mnt/swarm_data/Assistent/personas.json` (gpu-rent seeds from `assistent-personas.yaml` → yaml + json). Assistent reads **json** only. +**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures, not model encyclopedia (facts live in memory-seed). -**Taste:** `/mnt/swarm_data/Assistent/taste.json` via `AssistentGetTaste` / `AssistentSaveTaste` (merged with browser localStorage by `updated`). - -**Cards:** `{stem}.assistent.json` next to weights; Civitai sidecar `{stem}.civitai.json`. Wanted queue → `.gpu-rent-wanted-models.yaml`. +**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse` under `Config/personas/`. ## API routes | Route | Role | | --- | --- | -| `AssistentListModels` | Ollama `/api/tags` | +| `AssistentListModels` | Ollama tags → `models` (chat) + `memory_models` | +| `AssistentGetConfig` | Merged preset for persona (ui, packs, skills, identity) | +| `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) | | `AssistentListInventory` | LoRA / checkpoint / wildcard inventory | -| `AssistentListPersonas` | Bundled + overlay personas | +| `AssistentListPersonas` | Persona catalog | | `AssistentGetPacks` | Prompt pack texts | -| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards | -| `AssistentGetCardMeta` | Local sidecar + optional `fetch=true` Civitai by-hash | +| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) | +| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash | | `AssistentEnqueueWanted` | Wanted YAML queue | | `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile | | `AssistentSearchCivitai` | Civitai LoRA search | -| `AssistentChat` | HTTP chat (+ Civitai hop) | -| `AssistentChatWS` | Streaming chat WebSocket | +| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) | ## License diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 6f65268..51bd9cf 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -30,27 +30,15 @@ public class SwarmAssistentExtension : Extension public static HttpClient HttpClient; - public static readonly string[] PackNames = - [ - "base_krea2", - "write_prompt", - "critique_image", - "compose_scene", - "fix_params", - "inpaint_edit", - "describe_ref", - "catalog_card", - ]; + public AssistentConfig Config; + public AssistentMemory Memory; - public static readonly string[] DefaultPersonaIds = ["neutral", "lewd", "aggressive", "cinema", "terse"]; - - const int MaxCivitaiHops = 2; - const int MaxLorasInInventory = 150; - const int MaxWildcardsInInventory = 80; - const int MaxCheckpointsInInventory = 60; - const int InventoryBlurbMax = 140; - /// Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that. - const int DefaultNumCtx = 16384; + 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); @@ -59,18 +47,23 @@ public class SwarmAssistentExtension : Extension ScriptFiles.Add("Assets/assistent.js"); StyleSheetFiles.Add("Assets/assistent.css"); ExtensionAuthor = "mrleo1nid"; - Description = "Collaborative Krea 2 assistant: Ollama chat, multi-window board, personas, model cards, Generate loop, Civitai Confirm."; + Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.6.0"; - Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"]; + Version = "0.7.0"; + Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; } public override void OnInit() { HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + Config = new AssistentConfig(FilePath, DataRoot()); + Memory = new AssistentMemory(DataRoot(), HttpClient, Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text"); API.RegisterAPICall(AssistentListModels, false, PermUse); API.RegisterAPICall(AssistentGetPacks, false, PermUse); API.RegisterAPICall(AssistentListPersonas, false, PermUse); + API.RegisterAPICall(AssistentGetConfig, false, PermUse); + API.RegisterAPICall(AssistentGetSettings, false, PermUse); + API.RegisterAPICall(AssistentSaveSettings, true, PermUse); API.RegisterAPICall(AssistentListInventory, false, PermUse); API.RegisterAPICall(AssistentGetCard, false, PermUse); API.RegisterAPICall(AssistentSaveCard, true, PermUse); @@ -81,7 +74,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 (Ollama proxy + personas + model cards)"); + Logs.Init("Swarm Assistent extension loaded (Config presets + vector memory)"); + } + + int CfgInt(string key, int fallback) + { + try + { + return Config?.LoadAssistant(Config.DefaultPersonaId())[key]?.Value() ?? fallback; + } + catch + { + return fallback; + } } static string Clip(string text, int max) @@ -105,17 +110,7 @@ public class SwarmAssistentExtension : Extension public string ReadPackFile(string name) { - string safe = name.Replace('\\', '/').AfterLast('/').Replace("..", ""); - if (!PackNames.Contains(safe)) - { - return null; - } - string path = Path.Combine(FilePath, "Prompts", $"{safe}.md"); - if (!File.Exists(path)) - { - return null; - } - return File.ReadAllText(path, Encoding.UTF8); + return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name); } public async Task AssistentListModels(Session session, string baseUrl) @@ -130,12 +125,79 @@ public class SwarmAssistentExtension : Extension return new JObject { ["error"] = $"Ollama /api/tags HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" }; } JObject parsed = JObject.Parse(body); - JArray models = []; + JArray all = []; foreach (JToken m in parsed["models"] as JArray ?? []) { - models.Add(m["name"]?.ToString() ?? ""); + string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? ""; + if (!string.IsNullOrWhiteSpace(name)) + { + all.Add(name); + } } - return new JObject { ["success"] = true, ["base_url"] = root, ["models"] = models }; + 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) { @@ -143,18 +205,65 @@ public class SwarmAssistentExtension : Extension } } - public async Task AssistentGetPacks(Session session) + 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; + string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId(); JObject packs = new(); - foreach (string name in PackNames) + JArray order = []; + foreach (var p in Config.ListPacks(pid)) { - string text = ReadPackFile(name); + string text = Config.LoadPackPrompt(pid, p.id); if (text is not null) { - packs[name] = text; + packs[p.id] = text; + } + order.Add(p.id); + } + return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = order, ["persona"] = pid }; + } + + public async Task AssistentGetConfig(Session session, string persona = null) + { + await Task.CompletedTask; + string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId(); + return Config.BuildMergedConfigPayload(pid); + } + + public async Task AssistentGetSettings(Session session) + { + await Task.CompletedTask; + return new JObject { ["success"] = true, ["settings"] = Config.LoadSettings() }; + } + + public async Task AssistentSaveSettings(Session session, JObject settings) + { + await Task.CompletedTask; + if (settings is null) + { + return new JObject { ["error"] = "settings required" }; + } + string prevEmbed = Config.LoadSettings()["embed_model"]?.ToString(); + Config.SaveSettings(settings); + string nextEmbed = settings["embed_model"]?.ToString(); + if (!string.IsNullOrWhiteSpace(nextEmbed) && !string.Equals(prevEmbed, nextEmbed, StringComparison.OrdinalIgnoreCase)) + { + try + { + await Memory.ReembedAllAsync(NormalizeBaseUrl(settings["base_url"]?.ToString()), nextEmbed); + } + catch (Exception ex) + { + Logs.Debug($"AssistentSaveSettings reembed: {ex.Message}"); } } - return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = new JArray(PackNames) }; + return new JObject { ["success"] = true, ["path"] = Path.Combine(Config.OverlayRoot, "settings.json") }; } static string DataRoot() @@ -184,110 +293,26 @@ public class SwarmAssistentExtension : Extension string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards"); - public string ReadPersonaFile(string id) - { - string safe = (id ?? "").Replace('\\', '/').AfterLast('/').Replace("..", ""); - if (string.IsNullOrWhiteSpace(safe)) - { - return null; - } - string path = Path.Combine(FilePath, "Personas", $"{safe}.md"); - if (!File.Exists(path)) - { - return null; - } - return File.ReadAllText(path, Encoding.UTF8); - } - public async Task AssistentListPersonas(Session session) { await Task.CompletedTask; - Dictionary byId = new(StringComparer.OrdinalIgnoreCase); - string def = "neutral"; - - foreach (string id in DefaultPersonaIds) - { - string text = ReadPersonaFile(id); - if (string.IsNullOrWhiteSpace(text)) - { - continue; - } - byId[id] = new JObject - { - ["id"] = id, - ["title"] = id switch - { - "lewd" => "Пошляк", - "aggressive" => "Агрессивный", - "cinema" => "Кинооператор", - "terse" => "Короткий", - _ => "Нейтральный", - }, - ["prompt"] = text, - ["source"] = "bundled", - }; - } - - string overlay = PersonasOverlayJsonPath(); - if (File.Exists(overlay)) - { - try - { - JObject parsed = JObject.Parse(File.ReadAllText(overlay, Encoding.UTF8)); - if (parsed["default"] != null) - { - def = parsed["default"]?.ToString() ?? def; - } - if (parsed["personas"] is JArray arr) - { - foreach (JToken t in arr) - { - if (t is not JObject po) - { - continue; - } - string id = (po["id"]?.ToString() ?? "").Trim(); - if (string.IsNullOrWhiteSpace(id)) - { - continue; - } - string overlayPrompt = po["prompt"]?.ToString() ?? ""; - if (string.IsNullOrWhiteSpace(overlayPrompt) && byId.ContainsKey(id)) - { - // Keep bundled prompt when overlay prompt is empty. - byId[id]["title"] = po["title"]?.ToString() ?? byId[id]["title"]; - byId[id]["source"] = "overlay+bundled"; - continue; - } - byId[id] = new JObject - { - ["id"] = id, - ["title"] = po["title"]?.ToString() ?? id, - ["prompt"] = overlayPrompt, - ["source"] = "overlay", - }; - } - } - } - catch (Exception ex) - { - Logs.Debug($"AssistentListPersonas overlay: {ex.Message}"); - } - } - + var catalog = Config.ListPersonaCatalog(); JArray list = []; - foreach (JObject p in byId.Values.OrderBy(p => p["id"]?.ToString())) + foreach (var p in catalog) { - list.Add(p); - } - if (!byId.ContainsKey(def) && list.Count > 0) - { - def = list[0]?["id"]?.ToString() ?? "neutral"; + list.Add(new JObject + { + ["id"] = p.id, + ["title"] = p.title, + ["accent"] = p.accent, + ["prompt"] = Config.RenderIdentityBlock(p.id), + ["source"] = p.source, + }); } return new JObject { ["success"] = true, - ["default"] = def, + ["default"] = Config.DefaultPersonaId(), ["personas"] = list, }; } @@ -408,6 +433,7 @@ public class SwarmAssistentExtension : Extension { 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 }; } @@ -421,9 +447,53 @@ public class SwarmAssistentExtension : Extension { 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; @@ -923,7 +993,7 @@ public class SwarmAssistentExtension : Extension foreach (T2IModel model in loraHandler.Models.Values .OrderByDescending(m => LooksLikeKreaArch(m)) .ThenBy(m => m.Name) - .Take(MaxLorasInInventory)) + .Take(CfgInt("max_loras_inventory", MaxLorasInInventoryFallback))) { loras.Add(BuildInventoryModelEntry(model, "lora")); } @@ -934,7 +1004,7 @@ public class SwarmAssistentExtension : Extension foreach (T2IModel model in ckptHandler.Models.Values .OrderByDescending(m => LooksLikeKreaArch(m)) .ThenBy(m => m.Name) - .Take(MaxCheckpointsInInventory)) + .Take(CfgInt("max_checkpoints_inventory", MaxCheckpointsInInventoryFallback))) { checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint")); } @@ -942,7 +1012,7 @@ public class SwarmAssistentExtension : Extension try { - foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(MaxWildcardsInInventory)) + foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(CfgInt("max_wildcards_inventory", MaxWildcardsInInventoryFallback))) { wildcards.Add(new JObject { ["name"] = name }); } @@ -1005,7 +1075,7 @@ public class SwarmAssistentExtension : Extension string fromCard = (card["notes"] ?? card["when"] ?? card["prompt_hint"])?.ToString(); if (!string.IsNullOrWhiteSpace(fromCard)) { - blurb = Clip(fromCard.Trim(), InventoryBlurbMax); + blurb = Clip(fromCard.Trim(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback)); } } catch @@ -1018,7 +1088,7 @@ public class SwarmAssistentExtension : Extension string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc; if (!string.IsNullOrWhiteSpace(raw)) { - blurb = Clip(CollapseWs(raw), InventoryBlurbMax); + blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback)); } } @@ -1262,28 +1332,42 @@ public class SwarmAssistentExtension : Extension }; } - List BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null) + 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 basePack = ReadPackFile("base_krea2"); - if (!string.IsNullOrWhiteSpace(basePack)) + string core = Config.LoadCorePrompt(pid); + if (!string.IsNullOrWhiteSpace(core)) { - system.AppendLine(basePack); + system.AppendLine(core); } } - string personaPrompt = ResolvePersonaPrompt(personaId); - if (!string.IsNullOrWhiteSpace(personaPrompt)) + + 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($"## Persona: {personaId ?? "neutral"}"); - system.AppendLine(personaPrompt); + system.AppendLine(identity); } - if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2") + + if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2" && packName != "core") { - string situational = ReadPackFile(packName); + string situational = Config.LoadPackPrompt(pid, packName); if (!string.IsNullOrWhiteSpace(situational)) { system.AppendLine(); @@ -1332,41 +1416,7 @@ public class SwarmAssistentExtension : Extension return ollamaMessages; } - string ResolvePersonaPrompt(string personaId) - { - string id = (personaId ?? "neutral").Trim(); - if (string.IsNullOrWhiteSpace(id)) - { - id = "neutral"; - } - string overlay = PersonasOverlayJsonPath(); - if (File.Exists(overlay)) - { - try - { - JObject parsed = JObject.Parse(File.ReadAllText(overlay, Encoding.UTF8)); - if (parsed["personas"] is JArray arr) - { - foreach (JToken t in arr) - { - if (t is JObject po && string.Equals(po["id"]?.ToString(), id, StringComparison.OrdinalIgnoreCase)) - { - string p = po["prompt"]?.ToString(); - if (!string.IsNullOrWhiteSpace(p)) - { - return p; - } - } - } - } - } - catch - { - // fall through to bundled - } - } - return ReadPersonaFile(id); - } + string ResolvePersonaPrompt(string personaId) => Config.RenderIdentityBlock(personaId); static JObject TryParsePatch(string reply) { @@ -1396,7 +1446,7 @@ public class SwarmAssistentExtension : Extension || 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["pack"] != null || obj["memories"] != null || obj["memory"] != null)) { return obj; } @@ -1442,6 +1492,35 @@ public class SwarmAssistentExtension : Extension 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, @@ -1452,21 +1531,55 @@ public class SwarmAssistentExtension : Extension JArray userMessages, Func onDelta = null, Func onHopStart = null, - string personaId = null) + string personaId = null, + JArray skillIds = null, + string embedModel = null) { - List messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages, personaId: personaId); + 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; - for (int hop = 0; hop < MaxCivitaiHops; hop++) + 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); + (reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid); JObject patch = TryParsePatch(reply); - if (hop + 1 >= MaxCivitaiHops || !WantsCivitaiSearch(patch)) + await ApplyMemoryActions(root, patch, embed); + if (hop + 1 >= maxHops || !WantsCivitaiSearch(patch)) { break; } @@ -1501,13 +1614,180 @@ public class SwarmAssistentExtension : Extension 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 ctx; + try + { + ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson); + } + catch + { + ctx = new JObject { ["_raw_context"] = contextJson }; + } + ctx["memory_hits"] = hits ?? new JArray(); + // 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) + Func onDelta, + string personaId = null) { + int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value() + ?? DefaultNumCtxFallback; JObject payload = new() { ["model"] = modelName, @@ -1515,8 +1795,9 @@ public class SwarmAssistentExtension : Extension ["messages"] = new JArray(ollamaMessages), ["options"] = new JObject { - ["num_ctx"] = DefaultNumCtx, + ["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 }; @@ -1573,38 +1854,12 @@ public class SwarmAssistentExtension : Extension /// SwarmUI passes the whole request as the JObject param (not only a nested key). /// Support both flat fields and legacy nested raw. /// - static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona) - { - JObject whole = raw ?? []; - JObject 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"; - } + // 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); + 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)) @@ -1616,10 +1871,11 @@ public class SwarmAssistentExtension : Extension 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); + session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel); return new JObject { ["success"] = true, @@ -1640,7 +1896,7 @@ public class SwarmAssistentExtension : Extension /// 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); + 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)) @@ -1654,6 +1910,7 @@ public class SwarmAssistentExtension : Extension 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) @@ -1684,7 +1941,7 @@ public class SwarmAssistentExtension : Extension } } (string reply, JObject parsed, JArray civitai) = await RunChatWithHops( - session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona); + session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel); await ws.SendJson(new JObject { ["success"] = true, diff --git a/SwarmAssistentExtension.csproj b/SwarmAssistentExtension.csproj index 29e3dec..3c509ef 100644 --- a/SwarmAssistentExtension.csproj +++ b/SwarmAssistentExtension.csproj @@ -2,5 +2,8 @@ SwarmAssistentExtension + + + diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index 2e07792..c3243ad 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -41,14 +41,8 @@ - @@ -56,8 +50,15 @@