Bump Assistent to 0.7.0: Config personas, skills, and vector memory.

Move prompts into Config/_base and persona folders; seed model facts into SQLite via Ollama embed and retrieve as memory_hits each turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-21 22:47:04 +03:00
co-authored by Cursor
parent 46264d7227
commit fe4d8d3a3a
72 changed files with 2593 additions and 507 deletions
+17
View File
@@ -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;
+277 -48
View File
@@ -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 = `
<div class="sa-welcome-title">Assistent · Krea 2</div>
<ul>
<li><strong>Generate</strong> слева — живой просмотр. В чат сам не уходит.</li>
@@ -63,7 +64,7 @@
</ul>
Напиши, что сгенерировать — или кинь референс и попроси правку.`;
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();
});
+752
View File
@@ -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;
/// <summary>Loads Config/_base + personas/&lt;id&gt; sparse presets with disk overlay merge.</summary>
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;
}
}
/// <summary>Merge layered copies of the same relative path: bundled base → persona chain → disk base → disk persona.</summary>
public JObject MergeJsonLayers(string relative, IEnumerable<string> 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<string> 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<string> PersonaExtendsChain(string personaId)
{
List<string> chain = [];
HashSet<string> 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<string> 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<string, (string title, string accent, string source)> 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<bool?>() == 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<string, (string title, int order, string[] aliases, bool enabled)> 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<bool?>() != 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<int?>() ?? 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<bool?>() == 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<string, (string title, bool defaultOn, bool enabled)> 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<bool?>() ?? false,
meta["enabled"]?.Value<bool?>() != 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<bool>(), 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<bool?>() == 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<string> 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<string> 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<string> 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<JObject> LoadMemorySeedDocs()
{
List<JObject> 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<string> ResolveEnabledSkills(string personaId, JArray clientSkills)
{
var catalog = ListSkills(personaId);
HashSet<string> 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<bool>())
{
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)),
};
}
}
+428
View File
@@ -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;
/// <summary>Local SQLite vector memory with Ollama /api/embed.</summary>
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>();
}
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<float[]> 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<float>()).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<int?>() ?? 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<JObject> 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<JArray> 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;
}
}
}
+20
View File
@@ -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
}
+5
View File
@@ -0,0 +1,5 @@
{
"id": "core",
"title": "Core contract",
"prompt_file": "core.md"
}
+84
View File
@@ -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 01), `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.
+11
View File
@@ -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"]
}
+11
View File
@@ -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": []
}
+50
View File
@@ -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."
}
]
+32
View File
@@ -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 (1284096 OK)."
},
{
"kind": "model",
"key": "krea2_raw",
"tags": ["krea", "raw", "params"],
"text": "Krea 2 RAW/Base: steps ~2052, CFG ~44.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."
}
]
+20
View File
@@ -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.20.35) — do not rewrite the whole scene prompt."
}
]
+26
View File
@@ -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]
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "catalog_card",
"title": "Карточка модели",
"order": 70,
"aliases": ["card", "catalog"],
"prompt_file": "catalog_card.md"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "compose_scene",
"title": "Собрать сцену",
"order": 30,
"aliases": ["compose"],
"prompt_file": "compose_scene.md"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "critique_image",
"title": "Критика кадра",
"order": 20,
"aliases": ["critique"],
"prompt_file": "critique_image.md"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "describe_ref",
"title": "Описать ref",
"order": 60,
"aliases": ["describe"],
"prompt_file": "describe_ref.md"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "fix_params",
"title": "Параметры",
"order": 40,
"aliases": ["params"],
"prompt_file": "fix_params.md"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "inpaint_edit",
"title": "Inpaint / img2img",
"order": 50,
"aliases": ["inpaint"],
"prompt_file": "inpaint_edit.md"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": "write_prompt",
"title": "Написать промпт",
"order": 10,
"aliases": ["write"],
"prompt_file": "write_prompt.md"
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Base",
"tagline": "Default identity template",
"accent": "#8b949e"
}
+12
View File
@@ -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"
]
}
@@ -0,0 +1,6 @@
{
"id": "creativity_sliders",
"title": "Creativity lexicon",
"default": true,
"prompt_file": "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.
+6
View File
@@ -0,0 +1,6 @@
{
"id": "memory",
"title": "Vector memory",
"default": true,
"prompt_file": "memory.md"
}
+16
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
{
"id": "prompting",
"title": "Prompt craft",
"default": true,
"prompt_file": "prompting.md"
}
+9
View File
@@ -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.
+50
View File
@@ -0,0 +1,50 @@
{
"welcome_html": "<div class=\"sa-welcome-title\">Assistent · Krea 2</div><ul><li><strong>Generate</strong> слева — живой просмотр. В чат сам не уходит.</li><li><strong>Refs</strong> — референсы на отдельной вкладке: drop / paste / Снимок gen.</li><li>Галочка vision на окне — отправить кадр модели.</li><li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li><li>Кнопки патча только у последнего предложения.</li></ul>Напиши, что сгенерировать — или кинь референс и попроси правку.",
"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 <query> — поиск 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"
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "normal",
"tone": ["calm", "practical"],
"humor": "none",
"nsfw": "factual",
"address": "peer",
"language": "match_user"
}
+3
View File
@@ -0,0 +1,3 @@
{
"notes": ["soft padding", "возможно", "на ваш вкус", "filler"]
}
+3
View File
@@ -0,0 +1,3 @@
{
"notes": ["decisive patches", "call out weak prompts", "auto generate when re-roll is obvious"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Агрессивный",
"tagline": "Blunt high-agency director",
"accent": "#e5c07b"
}
+11
View File
@@ -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"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "terse",
"tone": ["blunt", "impatient", "decisive"],
"humor": "none",
"nsfw": "factual",
"address": "peer",
"language": "match_user"
}
+4
View File
@@ -0,0 +1,4 @@
{
"styles": ["smut-as-default", "tag-soup"],
"notes": ["lectures", "ignoring craft for smut"]
}
+8
View File
@@ -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"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Кинооператор",
"tagline": "Cinematographer co-director",
"accent": "#7aa2f7"
}
+9
View File
@@ -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"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "concise",
"tone": ["craft", "camera", "director"],
"humor": "none",
"nsfw": "factual",
"address": "peer",
"language": "match_user"
}
+3
View File
@@ -0,0 +1,3 @@
{
"notes": ["crude for its own sake", "refusing adult content", "lectures"]
}
+6
View File
@@ -0,0 +1,6 @@
{
"categories": ["sensual", "nsfw"],
"subjects": ["body", "fabric", "atmosphere"],
"moods": ["playful", "intimate"],
"notes": ["sensual detail when the user wants it"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Пошляк",
"tagline": "Playful spicy co-director",
"accent": "#e06c75"
}
+10
View File
@@ -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"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "normal",
"tone": ["flirty", "cheeky", "direct"],
"humor": "spicy",
"nsfw": "lean_in",
"address": "peer",
"language": "match_user"
}
+3
View File
@@ -0,0 +1,3 @@
{
"notes": ["dirty jokes", "aggression", "moral lectures", "softening NSFW"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"styles": ["clear craft advice"],
"notes": ["lighting", "composition", "LoRA triggers", "params"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Нейтральный",
"tagline": "Calm practical art director",
"accent": "#8b949e"
}
+11
View File
@@ -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"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "normal",
"tone": ["calm", "practical", "craft"],
"humor": "none",
"nsfw": "factual",
"address": "peer",
"language": "match_user"
}
+3
View File
@@ -0,0 +1,3 @@
{
"notes": ["lectures", "filler", "long explanations"]
}
+3
View File
@@ -0,0 +1,3 @@
{
"notes": ["decisive patches", "one main fix"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Короткий",
"tagline": "High-signal short replies",
"accent": "#56b6c2"
}
+10
View File
@@ -0,0 +1,10 @@
{
"always": [
"Reply in 12 short sentences, then the JSON patch",
"NSFW: factual, minimal words"
],
"never": [
"Lectures or filler",
"Invent LoRA names or triggers"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "minimal",
"tone": ["short", "high-signal"],
"humor": "none",
"nsfw": "factual",
"address": "peer",
"language": "match_user"
}
-8
View File
@@ -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.
-9
View File
@@ -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.
-8
View File
@@ -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.
-8
View File
@@ -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.
-9
View File
@@ -1,9 +0,0 @@
# Persona: terse
You are a short, high-signal art director.
- Match the user's language (RU or EN).
- Reply in **12 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.
-149
View File
@@ -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** (1284096 OK).
- **RAW / Base:** steps ~2052, CFG ~44.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 users 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, 01), `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 1284096 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.250.45**; restyle **0.50.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.
+38 -34
View File
@@ -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/<id>/ # 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 <query>` | Ask LLM to search Civitai (Confirm still required) |
| `/pack write\|critique\|` | Switch pack |
| `/civitai <query>` | 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
+484 -227
View File
@@ -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;
/// <summary>Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that.</summary>
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<int?>() ?? 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<JObject> 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<string> chatSet = new(
(roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
StringComparer.OrdinalIgnoreCase);
HashSet<string> memSet = new(
(roles["memory"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
StringComparer.OrdinalIgnoreCase);
// Heuristic fallbacks when sidecar missing
if (chatSet.Count == 0 && memSet.Count == 0)
{
foreach (JToken t in all)
{
string n = t.ToString();
if (LooksLikeEmbedModel(n))
{
memSet.Add(n);
}
else
{
chatSet.Add(n);
}
}
}
else
{
// Keep only tags that exist; anything unlabeled goes to chat if not memory
foreach (JToken t in all)
{
string n = t.ToString();
if (memSet.Contains(n) || LooksLikeEmbedModel(n))
{
memSet.Add(n);
chatSet.Remove(n);
}
else if (chatSet.Count == 0 || chatSet.Contains(n))
{
chatSet.Add(n);
}
else if (!memSet.Contains(n))
{
chatSet.Add(n);
}
}
}
JArray models = new(all.Select(t => t.ToString()).Where(n => chatSet.Contains(n) && !memSet.Contains(n) && !LooksLikeEmbedModel(n)));
JArray memoryModels = new(all.Select(t => t.ToString()).Where(n => memSet.Contains(n) || LooksLikeEmbedModel(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
if (memoryModels.Count == 0)
{
string fallback = Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text";
if (all.Any(t => string.Equals(t.ToString(), fallback, StringComparison.OrdinalIgnoreCase)
|| t.ToString().StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)))
{
memoryModels.Add(all.Select(t => t.ToString()).First(n =>
string.Equals(n, fallback, StringComparison.OrdinalIgnoreCase)
|| n.StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)));
}
}
return new JObject
{
["success"] = true,
["base_url"] = root,
["models"] = models,
["memory_models"] = memoryModels,
};
}
catch (Exception ex)
{
@@ -143,18 +205,65 @@ public class SwarmAssistentExtension : Extension
}
}
public async Task<JObject> 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<JObject> 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<JObject> AssistentGetConfig(Session session, string persona = null)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
return Config.BuildMergedConfigPayload(pid);
}
public async Task<JObject> AssistentGetSettings(Session session)
{
await Task.CompletedTask;
return new JObject { ["success"] = true, ["settings"] = Config.LoadSettings() };
}
public async Task<JObject> 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<JObject> AssistentListPersonas(Session session)
{
await Task.CompletedTask;
Dictionary<string, JObject> 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<int?>() ?? 0, card["title"]?.ToString() ?? name, card);
}
_ = IngestCardToMemory(card, name);
return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
}
async Task IngestCardToMemory(JObject card, string name)
{
if (Memory is null || card is null)
{
return;
}
try
{
string kind = (card["kind"]?.ToString() ?? "lora").Trim().ToLowerInvariant();
string key = (card["name"]?.ToString() ?? name ?? "").Trim();
List<string> bits = [];
foreach (string field in new[] { "when", "avoid", "prompt_hint", "notes" })
{
string v = card[field]?.ToString();
if (!string.IsNullOrWhiteSpace(v))
{
bits.Add($"{field}: {v.Trim()}");
}
}
if (card["triggers"] is JArray tr)
{
string joined = string.Join(", ", tr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
if (!string.IsNullOrWhiteSpace(joined))
{
bits.Add("triggers: " + joined);
}
}
if (bits.Count == 0 || string.IsNullOrWhiteSpace(key))
{
return;
}
string text = $"{kind} {key}. " + string.Join(" ", bits);
string baseUrl = NormalizeBaseUrl(Config.LoadSettings()["base_url"]?.ToString());
string embedModel = Config.LoadSettings()["embed_model"]?.ToString()
?? Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString();
await Memory.UpsertTextAsync(baseUrl, "card", key, text, "user", card, embedModel);
}
catch (Exception ex)
{
Logs.Debug($"IngestCardToMemory: {ex.Message}");
}
}
public async Task<JObject> 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<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null)
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
{
List<JObject> ollamaMessages = [];
StringBuilder system = new();
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
if (includeBase)
{
string 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<bool?>("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<string, Task> onDelta = null,
Func<int, Task> onHopStart = null,
string personaId = null)
string personaId = null,
JArray skillIds = null,
string embedModel = null)
{
List<JObject> messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages, personaId: personaId);
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
List<string> skills = Config.ResolveEnabledSkills(pid, skillIds);
string embed = string.IsNullOrWhiteSpace(embedModel)
? (Config.LoadSettings()["embed_model"]?.ToString()
?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
?? "nomic-embed-text")
: embedModel;
try
{
await Memory.EnsureSeedAsync(root, Config, embed);
}
catch (Exception ex)
{
Logs.Debug($"Assistent memory seed: {ex.Message}");
}
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson);
JArray hits = [];
try
{
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 10;
hits = await Memory.RetrieveAsync(root, retrieveQuery, topK, embed);
}
catch (Exception ex)
{
Logs.Debug($"Assistent memory retrieve: {ex.Message}");
}
string enrichedContext = InjectMemoryHits(contextJson, hits);
List<JObject> messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
JArray civitaiResults = [];
string reply = "";
JObject lastRaw = null;
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<string> keep = new(StringComparer.OrdinalIgnoreCase);
if (ctx["enabled_loras"] is JArray en)
{
foreach (JToken t in en)
{
string n = t?["name"]?.ToString() ?? t?.ToString();
if (!string.IsNullOrWhiteSpace(n))
{
keep.Add(n);
}
}
}
foreach (JToken hit in hits ?? [])
{
if (string.Equals(hit?["kind"]?.ToString(), "lora", StringComparison.OrdinalIgnoreCase)
|| string.Equals(hit?["kind"]?.ToString(), "card", StringComparison.OrdinalIgnoreCase))
{
string k = hit?["key"]?.ToString();
if (!string.IsNullOrWhiteSpace(k))
{
keep.Add(k);
}
}
}
JArray slim = [];
foreach (JToken t in allLoras)
{
string n = t?["name"]?.ToString();
if (!string.IsNullOrWhiteSpace(n) && (keep.Contains(n) || slim.Count < 12))
{
if (keep.Contains(n) || t?["krea_likely"]?.Value<bool>() == true)
{
slim.Add(t);
}
}
}
if (slim.Count == 0)
{
foreach (JToken t in allLoras.Take(12))
{
slim.Add(t);
}
}
ctx["available_loras"] = slim;
ctx["available_loras_truncated"] = true;
ctx["available_loras_total"] = allLoras.Count;
}
return ctx.ToString(Newtonsoft.Json.Formatting.None);
}
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<JObject> ollamaMessages,
bool stream,
Func<string, Task> onDelta)
Func<string, Task> onDelta,
string personaId = null)
{
int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value<int?>()
?? DefaultNumCtxFallback;
JObject payload = new()
{
["model"] = modelName,
@@ -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 <c>raw</c>.
/// </summary>
static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona)
{
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<bool?>("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
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
{
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona);
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
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
{
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona);
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,
+3
View File
@@ -2,5 +2,8 @@
<PropertyGroup>
<AssemblyName>SwarmAssistentExtension</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
</ItemGroup>
<Import Project="../../SwarmUI.extension.props" />
</Project>
+8 -7
View File
@@ -41,14 +41,8 @@
</select>
<select id="sa_pack" class="sa-select" title="Пакет промпта">
<option value="write_prompt">Написать промпт</option>
<option value="critique_image">Критика кадра</option>
<option value="compose_scene">Собрать сцену</option>
<option value="fix_params">Параметры</option>
<option value="inpaint_edit">Inpaint / img2img</option>
<option value="describe_ref">Описать ref</option>
<option value="catalog_card">Карточка модели</option>
</select>
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama">
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama (чат)">
<option value="">Загрузка моделей…</option>
</select>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_settings" title="Настройки" aria-label="Настройки"></button>
@@ -56,8 +50,15 @@
</header>
<div class="sa-settings" id="sa_settings" hidden>
<label>Ollama URL <input type="text" id="sa_base_url" value="http://127.0.0.1:11434" /></label>
<label>Модель памяти
<select id="sa_embed_model" class="sa-select" title="Хранение и группировка памяти (use: memory)">
<option value="nomic-embed-text">nomic-embed-text</option>
</select>
</label>
<button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button>
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
<div class="sa-skills-label">Скилы (процедуры)</div>
<div class="sa-skills-box" id="sa_skills_box"></div>
<label class="sa-check"><input type="checkbox" id="sa_auto_vision" /> Авто-прикреплять Generate к чату</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>