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