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
- Generate слева — живой просмотр. В чат сам не уходит.
@@ -63,7 +64,7 @@
Напиши, что сгенерировать — или кинь референс и попроси правку.`;
- 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 @@
-