Ship Assistent 0.14.0: chat sessions, ask-only hops, and context compression.

Per-chat Generate session with sparse deltas; drop Cards/Civitai/wanted hops; rolling history summary via the same Ollama model with a budget chip and /compress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 06:19:04 +03:00
co-authored by Cursor
parent d29436c944
commit 9ac24a828b
35 changed files with 5640 additions and 5652 deletions
+1703 -1414
View File
File diff suppressed because it is too large Load Diff
+469 -45
View File
@@ -118,15 +118,15 @@
}
.sa-chats-drawer {
flex: 0 0 var(--sa-chats-drawer-width, 16rem);
width: var(--sa-chats-drawer-width, 16rem);
flex: 0 0 var(--sa-chats-drawer-width, 17.5rem);
width: var(--sa-chats-drawer-width, 17.5rem);
display: flex;
flex-direction: column;
gap: 0.35rem;
gap: 0.45rem;
min-height: 0;
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 4%, transparent);
padding: 0.45rem 0.5rem;
border-left: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, #000 22%, transparent);
padding: 0.55rem 0.4rem 0.55rem 0.45rem;
overflow: hidden;
transition: width 0.2s ease, flex-basis 0.2s ease, opacity 0.2s ease;
}
@@ -140,12 +140,54 @@
align-items: center;
justify-content: space-between;
gap: 0.35rem;
font-size: 0.82rem;
padding: 0.15rem 0.35rem 0.1rem;
}
.sa-chats-drawer-label {
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.55;
}
.sa-chats-drawer-actions {
display: inline-flex;
gap: 0.2rem;
gap: 0.1rem;
}
.sa-chats-icon-btn {
appearance: none;
border: 0;
background: transparent;
color: inherit;
width: 1.65rem;
height: 1.65rem;
border-radius: 0.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0.55;
}
.sa-chats-icon-btn:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
}
.sa-chats-search-wrap {
position: relative;
margin: 0 0.2rem;
}
.sa-chats-search-ico {
position: absolute;
left: 0.55rem;
top: 50%;
transform: translateY(-50%);
opacity: 0.4;
pointer-events: none;
}
.sa-layout {
@@ -555,8 +597,8 @@
box-shadow: none;
border-radius: 0;
border: none;
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 4%, transparent);
border-left: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: color-mix(in srgb, #000 22%, transparent);
}
.sa-chats-panel-head {
@@ -570,51 +612,67 @@
appearance: none;
width: 100%;
box-sizing: border-box;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, #000 28%, transparent);
border: 1px solid transparent;
background: color-mix(in srgb, currentColor 7%, transparent);
color: inherit;
border-radius: 0.35rem;
padding: 0.35rem 0.5rem;
border-radius: 0.5rem;
padding: 0.42rem 0.55rem 0.42rem 1.7rem;
font: inherit;
font-size: 0.82rem;
font-size: 0.8rem;
}
.sa-chats-search:focus {
outline: 1px solid color-mix(in srgb, #6cf 55%, currentColor);
outline: none;
border-color: color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, currentColor 10%, transparent);
}
.sa-chats-panel-hint {
font-size: 0.72rem;
opacity: 0.65;
line-height: 1.35;
display: none;
}
.sa-chats-list {
overflow: auto;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.1rem;
min-height: 0;
padding: 0.15rem 0.15rem 0.4rem;
flex: 1;
}
.sa-chats-empty {
font-size: 0.82rem;
opacity: 0.65;
padding: 0.5rem 0.25rem;
font-size: 0.8rem;
opacity: 0.5;
padding: 0.85rem 0.55rem;
line-height: 1.4;
}
.sa-chat-row {
display: flex;
align-items: stretch;
gap: 0.2rem;
border-radius: 0.4rem;
border: 1px solid transparent;
background: color-mix(in srgb, currentColor 5%, transparent);
align-items: center;
gap: 0;
border-radius: 0.5rem;
border: 0;
background: transparent;
position: relative;
}
.sa-chat-row-active {
border-color: color-mix(in srgb, #6cf 45%, currentColor);
background: color-mix(in srgb, #6cf 12%, transparent);
background: color-mix(in srgb, currentColor 11%, transparent);
}
.sa-chat-row-active::before {
content: '';
position: absolute;
left: 0.35rem;
top: 50%;
transform: translateY(-50%);
width: 0.35rem;
height: 0.35rem;
border-radius: 50%;
background: #f59e0b;
box-shadow: 0 0 0 2px color-mix(in srgb, #f59e0b 25%, transparent);
}
.sa-chat-row-main {
@@ -625,27 +683,60 @@
text-align: left;
flex: 1;
min-width: 0;
padding: 0.4rem 0.5rem;
padding: 0.48rem 0.45rem 0.48rem 0.55rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.12rem;
display: grid;
grid-template-columns: 1.1rem 1fr auto;
align-items: center;
gap: 0.45rem;
border-radius: 0.5rem;
}
.sa-chat-row-active .sa-chat-row-main {
padding-left: 1.05rem;
}
.sa-chat-row-main:hover {
background: color-mix(in srgb, currentColor 7%, transparent);
}
.sa-chat-row-active .sa-chat-row-main:hover {
background: transparent;
}
.sa-chat-row-ico {
width: 1.05rem;
height: 1.05rem;
opacity: 0.45;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sa-chat-row-active .sa-chat-row-ico {
opacity: 0.75;
}
.sa-chat-row-title {
font-size: 0.85rem;
font-weight: 600;
font-size: 0.84rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.sa-chat-row-meta {
.sa-chat-row-active .sa-chat-row-title {
font-weight: 600;
}
.sa-chat-row-when {
font-size: 0.72rem;
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.42;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
padding-right: 0.15rem;
}
.sa-chat-row-del {
@@ -653,16 +744,28 @@
border: 0;
background: transparent;
color: inherit;
opacity: 0.55;
opacity: 0;
cursor: pointer;
padding: 0 0.55rem;
font-size: 1.1rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 0.35rem;
font-size: 0.95rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 0.15rem;
}
.sa-chat-row:hover .sa-chat-row-del {
opacity: 0.45;
}
.sa-chat-row-del:hover {
opacity: 1;
opacity: 1 !important;
color: #f66;
background: color-mix(in srgb, #f66 12%, transparent);
}
.sa-live-dot {
@@ -1213,6 +1316,180 @@
border-color: color-mix(in srgb, #f2777a 45%, transparent);
}
.sa-ctx-wrap {
position: relative;
display: inline-flex;
align-items: center;
}
.sa-ctx-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-variant-numeric: tabular-nums;
padding: 0.15rem 0.5rem;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
background: color-mix(in srgb, currentColor 6%, transparent);
cursor: pointer;
white-space: nowrap;
color: inherit;
line-height: 1.2;
}
.sa-ctx-chip:hover {
border-color: color-mix(in srgb, currentColor 45%, transparent);
}
.sa-ctx-dot {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: color-mix(in srgb, #6ea8fe 80%, currentColor);
flex-shrink: 0;
}
.sa-ctx-ok {
border-color: color-mix(in srgb, #6ee7a8 35%, transparent);
}
.sa-ctx-warn {
color: color-mix(in srgb, #e3b341 85%, currentColor);
border-color: color-mix(in srgb, #e3b341 45%, transparent);
}
.sa-ctx-hot {
color: color-mix(in srgb, #f2777a 85%, currentColor);
border-color: color-mix(in srgb, #f2777a 50%, transparent);
}
.sa-ctx-compressing {
color: color-mix(in srgb, #6ea8fe 85%, currentColor);
border-color: color-mix(in srgb, #6ea8fe 50%, transparent);
}
.sa-ctx-panel {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 40;
width: min(22rem, 78vw);
padding: 0.65rem 0.75rem 0.75rem;
border-radius: 0.55rem;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, #1a1d24 92%, transparent);
box-shadow: 0 0.6rem 1.4rem color-mix(in srgb, #000 45%, transparent);
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.sa-ctx-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
font-size: 0.85rem;
}
.sa-ctx-bar {
height: 0.35rem;
border-radius: 0.25rem;
background: color-mix(in srgb, currentColor 12%, transparent);
overflow: hidden;
}
.sa-ctx-bar-fill {
height: 100%;
width: 0%;
background: color-mix(in srgb, #6ee7a8 70%, currentColor);
transition: width 0.2s ease;
}
.sa-ctx-bar-fill[data-level="warn"] {
background: color-mix(in srgb, #e3b341 75%, currentColor);
}
.sa-ctx-bar-fill[data-level="hot"] {
background: color-mix(in srgb, #f2777a 75%, currentColor);
}
.sa-ctx-panel-body {
font-size: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 14rem;
overflow: auto;
}
.sa-ctx-layer {
display: flex;
justify-content: space-between;
gap: 0.5rem;
opacity: 0.85;
}
.sa-ctx-layers-label,
.sa-ctx-meta,
.sa-ctx-see {
opacity: 0.65;
font-size: 0.7rem;
margin-top: 0.15rem;
}
.sa-ctx-summary {
margin: 0.2rem 0 0;
padding: 0.4rem 0.45rem;
font-size: 0.7rem;
white-space: pre-wrap;
word-break: break-word;
max-height: 7rem;
overflow: auto;
border-radius: 0.35rem;
background: color-mix(in srgb, currentColor 8%, transparent);
border: 1px solid color-mix(in srgb, currentColor 12%, transparent);
}
.sa-ctx-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.sa-ctx-auto {
font-size: 0.75rem;
}
.sa-msg-compress {
margin: 0.35rem 0.55rem;
padding: 0.35rem 0.55rem;
border-radius: 0.45rem;
border: 1px dashed color-mix(in srgb, #6ea8fe 40%, transparent);
background: color-mix(in srgb, #6ea8fe 8%, transparent);
font-size: 0.8rem;
}
.sa-msg-compress > summary {
cursor: pointer;
font-weight: 600;
}
.sa-msg-compress-body {
margin-top: 0.4rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.sa-msg-compress-row {
opacity: 0.8;
font-size: 0.72rem;
white-space: pre-wrap;
word-break: break-word;
}
.sa-card-row-wanted {
border-color: color-mix(in srgb, #e3b341 40%, transparent);
}
@@ -2481,3 +2758,150 @@
pointer-events: none;
opacity: 0.45;
}
/* Turn activity timeline (model commands + pipeline) */
.sa-activity {
align-self: stretch;
margin: 0.35rem 0 0.55rem;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.65rem;
background:
linear-gradient(180deg,
color-mix(in srgb, currentColor 7%, transparent),
color-mix(in srgb, currentColor 3%, transparent));
overflow: hidden;
box-shadow: 0 1px 0 color-mix(in srgb, #fff 4%, transparent) inset;
}
.sa-activity-live {
border-color: color-mix(in srgb, #6af 35%, transparent);
}
.sa-activity-done {
opacity: 0.92;
}
.sa-activity-head {
display: flex;
align-items: center;
gap: 0.55rem;
width: 100%;
padding: 0.55rem 0.75rem;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.sa-activity-head:hover {
background: color-mix(in srgb, currentColor 5%, transparent);
}
.sa-activity-spin {
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
border: 1.5px solid color-mix(in srgb, currentColor 25%, transparent);
border-top-color: color-mix(in srgb, #8cf 90%, currentColor);
flex-shrink: 0;
}
.sa-activity-live .sa-activity-spin {
animation: sa-spin 0.7s linear infinite;
}
.sa-activity-done .sa-activity-spin {
border-color: color-mix(in srgb, #6c6 55%, transparent);
border-top-color: #6c6;
animation: none;
background: color-mix(in srgb, #6c6 35%, transparent);
}
.sa-activity-title {
flex: 1;
min-width: 0;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sa-activity-chev {
opacity: 0.45;
font-size: 0.75rem;
transition: transform 0.15s ease;
}
.sa-activity-collapsed .sa-activity-chev {
transform: rotate(-90deg);
}
.sa-activity-collapsed .sa-activity-steps {
display: none;
}
.sa-activity-steps {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0 0.55rem 0.6rem 0.75rem;
}
.sa-activity-step {
display: grid;
grid-template-columns: 1.1rem 1fr;
gap: 0.45rem;
align-items: start;
padding: 0.28rem 0.35rem;
border-radius: 0.4rem;
}
.sa-activity-step.sa-activity-running {
background: color-mix(in srgb, #6af 10%, transparent);
}
.sa-activity-step.sa-activity-done {
opacity: 0.85;
}
.sa-activity-step.sa-activity-skip {
opacity: 0.45;
}
.sa-activity-step.sa-activity-error {
background: color-mix(in srgb, #c44 12%, transparent);
}
.sa-activity-icon {
font-size: 0.78rem;
line-height: 1.35;
opacity: 0.7;
text-align: center;
}
.sa-activity-running .sa-activity-icon {
opacity: 1;
color: color-mix(in srgb, #8cf 80%, currentColor);
}
.sa-activity-label {
font-size: 0.86rem;
line-height: 1.35;
}
.sa-activity-detail {
margin-top: 0.1rem;
font-size: 0.78rem;
line-height: 1.35;
opacity: 0.62;
word-break: break-word;
}
@keyframes sa-spin {
to { transform: rotate(360deg); }
}
+104 -246
View File
@@ -12,14 +12,17 @@ using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Prompt assembly, memory retrieval/writeback and the Civitai search hop loop.</summary>
/// <summary>Prompt assembly, memory retrieval, and ask-only server hop loop (settings / inventory).</summary>
public partial class SwarmAssistentExtension
{
const int MaxCivitaiHopsFallback = 2;
const int MaxToolHopsFallback = 4;
const int MaxToolHopsFallback = 2;
static bool IsSlimDebugPack(string packName) =>
string.Equals(packName, "debug_explain", StringComparison.OrdinalIgnoreCase);
static bool IsSlimUtilityPack(string packName) =>
string.Equals(packName, "debug_explain", StringComparison.OrdinalIgnoreCase)
|| string.Equals(packName, "compress_history", StringComparison.OrdinalIgnoreCase);
[Obsolete("Use IsSlimUtilityPack")]
static bool IsSlimDebugPack(string packName) => IsSlimUtilityPack(packName);
(List<JObject> messages, JObject systemLayers) BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
{
@@ -48,9 +51,9 @@ public partial class SwarmAssistentExtension
AddLayer("core", Config.LoadCorePrompt(pid));
}
bool slimDebug = IsSlimDebugPack(packName);
bool slimUtility = IsSlimUtilityPack(packName);
if (Memory is not null && !slimDebug)
if (Memory is not null && !slimUtility)
{
try
{
@@ -77,7 +80,7 @@ public partial class SwarmAssistentExtension
+ exact.ToString(Newtonsoft.Json.Formatting.None) + "\n```");
}
if (!slimDebug)
if (!slimUtility)
{
StringBuilder skillsBlock = new();
foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
@@ -176,9 +179,9 @@ public partial class SwarmAssistentExtension
Logs.Debug($"Assistent memory seed: {ex.Message}");
}
bool slimDebug = IsSlimDebugPack(packName);
bool slimUtility = IsSlimUtilityPack(packName);
JArray hits = [];
if (!slimDebug)
if (!slimUtility)
{
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson, packName);
try
@@ -194,7 +197,7 @@ public partial class SwarmAssistentExtension
}
string enrichedContext = InjectMemoryHits(contextJson, hits, pid);
if (!slimDebug)
if (!slimUtility)
{
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
}
@@ -202,14 +205,10 @@ public partial class SwarmAssistentExtension
int systemChars = systemLayers["total"]?.Value<int?>()
?? messages.FirstOrDefault(m => string.Equals(m["role"]?.ToString(), "system", StringComparison.OrdinalIgnoreCase))?["content"]?.ToString()?.Length
?? 0;
JArray civitaiResults = [];
string reply = "";
JObject lastRaw = null;
int maxHops = slimDebug
? 1
: Math.Max(CfgInt("max_civitai_hops", MaxCivitaiHopsFallback), CfgInt("max_tool_hops", MaxToolHopsFallback));
int maxHops = slimUtility ? 1 : CfgInt("max_tool_hops", MaxToolHopsFallback);
HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase);
var chain = Config.PersonaExtendsChain(pid);
for (int hop = 0; hop < maxHops; hop++)
{
if (onHopStart is not null)
@@ -217,20 +216,17 @@ public partial class SwarmAssistentExtension
await onHopStart(hop);
}
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
if (slimDebug)
if (slimUtility)
{
break;
}
JObject patch = TryParsePatch(reply);
await ApplyMemoryActions(root, patch, embed, pid);
ApplyUserPrefActions(patch, pid);
ApplyPersonaActions(patch, ref pid);
// 0.14: no ApplyMemoryActions / ApplyUserPrefActions / ApplyPersonaActions / Civitai from server hop loop.
if (hop + 1 >= maxHops)
{
break;
}
string follow = null;
JArray civitaiHop = null;
HashSet<string> hopSkip = new(StringComparer.OrdinalIgnoreCase);
while (true)
{
@@ -240,7 +236,7 @@ public partial class SwarmAssistentExtension
follow = null;
break;
}
(follow, civitaiHop) = await RunToolHop(session, root, embed, pid, chain, patch, tool, hopDone);
follow = await RunToolHop(session, pid, patch, tool, hopDone);
if (follow is not null)
{
break;
@@ -251,10 +247,6 @@ public partial class SwarmAssistentExtension
{
break;
}
if (civitaiHop is { Count: > 0 })
{
civitaiResults = civitaiHop;
}
// Re-feed only the parsed patch JSON (not full prose) to save hop tokens.
string assistantContent = patch is not null
? patch.ToString(Newtonsoft.Json.Formatting.None)
@@ -262,7 +254,7 @@ public partial class SwarmAssistentExtension
messages.Add(new JObject { ["role"] = "assistant", ["content"] = assistantContent });
messages.Add(new JObject { ["role"] = "user", ["content"] = follow });
}
return (reply, lastRaw, civitaiResults, systemChars, systemLayers);
return (reply, lastRaw, [], systemChars, systemLayers);
}
static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null)
@@ -384,241 +376,111 @@ public partial class SwarmAssistentExtension
return filtered;
}
async Task<(string follow, JArray civitai)> RunToolHop(
/// <summary>Ask-only server hops: Exact/assistant settings dump or truncated inventory.</summary>
async Task<string> RunToolHop(
Session session,
string root,
string embed,
string pid,
IEnumerable<string> chain,
JObject patch,
string tool,
HashSet<string> hopDone)
{
if (tool == "memory_get")
if (tool == "ask_settings")
{
JArray got = [];
foreach (JToken t in patch["memories"] as JArray ?? [])
if (!hopDone.Add("ask_settings"))
{
if (t is not JObject mo)
{
continue;
return null;
}
string kind = mo["kind"]?.ToString() ?? "note";
string key = mo["key"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(key))
{
continue;
JObject dump = BuildAskSettingsDump(pid);
return
"ask:settings dump (Exact + assistant knobs from server). "
+ "Live session fields arrive via client compact context. "
+ "Reply with a sparse delta JSON only if needed; omit ask:settings unless you need a refresh.\n```json\n"
+ dump.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
}
string sig = $"get:{kind}:{key}";
if (tool == "ask_inventory")
{
string q = patch?["inventory_query"]?.ToString()?.Trim() ?? "";
string sig = "ask_inventory:" + q.ToLowerInvariant();
if (!hopDone.Add(sig))
{
continue;
}
JObject row = Memory.Get(kind, key, chain);
got.Add(row ?? new JObject { ["kind"] = kind, ["key"] = key, ["missing"] = true });
}
if (got.Count == 0)
{
return (null, null);
}
return (
"memory_get results (JSON). Use these facts; omit memory_get unless you need a different key.\n```json\n"
+ got.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "memory_search")
{
string q = ExtractMemoryQuery(patch);
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("search:" + q))
{
return (null, null);
}
string kind = patch["memory_kind"]?.ToString();
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 8;
JArray rows = await Memory.SearchAsync(root, q, kind, topK, embed, chain);
return (
"memory_search results (JSON, hybrid FTS+vector). Omit memory_search unless you need a different query.\n```json\n"
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "heard_search")
{
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() == false)
{
return ("heard_search disabled in training-agent settings.", null);
}
string q = patch["memory_query"]?.ToString()?.Trim()
?? patch["search_query"]?.ToString()?.Trim()
?? ExtractMemoryQuery(patch);
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("heard:" + q))
{
return (null, null);
}
int topK = Config.LoadTrainingAgent()["heard_quota"]?.Value<int?>() ?? 3;
JArray rows = await Memory.SearchAsync(root, q, AssistentMemory.HeardKind, topK, embed, chain);
JArray examples = [];
foreach (JToken t in rows)
{
if (t is JObject ho)
{
JObject ex = Memory.BuildHeardExampleFromHit(ho, chain);
if (ex is not null)
{
examples.Add(ex);
}
}
}
return (
"heard_search — curated dialogue examples the assistant learned (style/reference, not hard rules). Use tone and structure; omit heard_search unless you need more examples.\n```json\n"
+ examples.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "lookup_tags")
{
string q = ExtractTagQuery(patch);
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("tags:" + q))
{
return (null, null);
}
int lim = Config.LoadAssistant(pid)["tag_lookup_limit"]?.Value<int?>() ?? 20;
JArray tags = Memory.LookupTags(q, lim);
return (
"lookup_tags results from Danbooru csv (canonical name, aliases, post_count). Krea prompts stay natural prose — use this to check spelling/aliases, do not dump tag soup.\n```json\n"
+ tags.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "list_inventory")
{
string q = patch["inventory_query"]?.ToString()?.Trim() ?? "";
string sig = "inv:" + q.ToLowerInvariant();
if (!hopDone.Add(sig))
{
return (null, null);
return null;
}
int lim = Config.LoadAssistant(pid)["inventory_hop_limit"]?.Value<int?>() ?? 20;
JArray rows = SearchInventoryForHop(q, lim);
return (
"list_inventory results (rich LoRA/checkpoint rows). Use exact names + listed triggers; omit list_inventory unless you need a different query.\n```json\n"
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "skill_load")
JArray rows;
if (string.IsNullOrWhiteSpace(q))
{
List<string> ids = [];
if (patch["skills"] is JArray skArr)
JObject inv = await AssistentListInventory(session, rescan: false);
rows = TruncateInventoryForAsk(inv, lim);
}
else
{
foreach (JToken t in skArr)
{
string sid = AssistentConfig.SafeId(t?.ToString());
if (!string.IsNullOrWhiteSpace(sid))
{
ids.Add(sid);
rows = SearchInventoryForHop(q, lim);
}
return
"ask:inventory truncated LoRA/checkpoint list. Use exact names + listed triggers; "
+ "omit ask:inventory unless you need a different query.\n```json\n"
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
}
}
if (ids.Count == 0)
{
ids.Add("memory");
}
StringBuilder sb = new();
foreach (string sid in ids)
{
string sig = "skill:" + sid;
if (!hopDone.Add(sig))
{
continue;
}
string text = Config.LoadSkillPrompt(pid, sid);
if (string.IsNullOrWhiteSpace(text))
{
continue;
}
sb.AppendLine($"## Skill: {sid}");
sb.AppendLine(text);
sb.AppendLine();
}
if (sb.Length == 0)
{
return (null, null);
}
return (
"skill_load results. Follow these skill rules on the next reply; omit skill_load unless you need another skill.\n\n"
+ sb.ToString().TrimEnd(),
null);
}
if (tool == "persona_read")
{
List<string> shelves = [];
if (patch["persona_shelves"] is JArray shArr)
{
foreach (JToken t in shArr)
{
string name = t?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(name))
{
shelves.Add(name);
}
}
}
else if (patch["persona_shelves"] is JObject shObj)
{
// Model sometimes echoes shelf objects; treat keys as names.
foreach (JProperty p in shObj.Properties())
{
shelves.Add(p.Name);
}
}
string sig = "persona_read:" + string.Join(",", shelves);
if (!hopDone.Add(sig))
{
return (null, null);
}
string body = Config.RenderPersonaReadBlock(pid, shelves.Count > 0 ? shelves : null);
if (string.IsNullOrWhiteSpace(body))
{
return ("persona_read: no additional lore shelves for this persona.", null);
}
return (
"persona_read results (lore shelves). Use for roleplay/appearance/outfit detail; omit persona_read unless you need different shelves.\n\n"
+ body,
null);
}
if (tool == "civitai")
{
string query = ExtractSearchQuery(patch);
if (string.IsNullOrWhiteSpace(query))
{
if (!hopDone.Add("civitai:missing_query"))
{
return (null, null);
}
return (
"search_civitai skipped: provide a short search_query (LoRA keywords only). "
+ "Never search with the whole user message. Then retry with actions:[\"search_civitai\"] + search_query, "
+ "or continue using available_loras only.",
null);
}
if (!hopDone.Add("civitai:" + query))
{
return (null, null);
}
JObject search = await AssistentSearchCivitai(session, query, 8);
if (search["error"] is not null)
{
return ($"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.", null);
}
JArray civitaiResults = search["results"] as JArray ?? [];
return (
"Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
"Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
"Omit search_civitai from actions unless you need a different query.\n```json\n" +
civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
civitaiResults);
}
return (null, null);
return null;
}
/// <summary>Substring filter over current LoRA/checkpoint inventory for list_inventory hop.</summary>
static JArray TruncateInventoryForAsk(JObject inv, int limit)
{
int lim = Math.Max(1, Math.Min(limit, 40));
JArray outRows = [];
foreach (JToken t in inv?["loras"] as JArray ?? [])
{
if (outRows.Count >= lim)
{
break;
}
outRows.Add(t);
}
foreach (JToken t in inv?["checkpoints"] as JArray ?? [])
{
if (outRows.Count >= lim)
{
break;
}
outRows.Add(t);
}
return outRows;
}
JObject BuildAskSettingsDump(string pid)
{
JObject exact = Config.LoadExact(pid) ?? new JObject();
JObject asst = Config.LoadAssistant(pid) ?? new JObject();
// Knobs the model may need — not the full assistant.json blob (quotas/seed noise).
JObject knobs = new()
{
["num_ctx"] = asst["num_ctx"],
["num_predict"] = asst["num_predict"],
["max_tool_hops"] = asst["max_tool_hops"],
["inventory_hop_limit"] = asst["inventory_hop_limit"],
["max_loras_inventory"] = asst["max_loras_inventory"],
["max_checkpoints_inventory"] = asst["max_checkpoints_inventory"],
["max_gen_variants"] = asst["max_gen_variants"],
["max_ref_slots"] = asst["max_ref_slots"],
["default_pack"] = asst["default_pack"],
["default_persona"] = asst["default_persona"],
["gate"] = asst["gate"],
["context_prompt_max"] = asst["context_prompt_max"],
["history_keep_turns"] = asst["history_keep_turns"],
["compress_at"] = asst["compress_at"],
["chars_per_token"] = asst["chars_per_token"],
["compress_auto"] = asst["compress_auto"],
};
return new JObject
{
["detail"] = "settings",
["exact"] = exact,
["assistant"] = knobs,
["persona"] = pid,
};
}
/// <summary>Substring filter over current LoRA/checkpoint inventory for ask:inventory hop.</summary>
JArray SearchInventoryForHop(string query, int limit)
{
int lim = Math.Max(1, Math.Min(limit, 40));
@@ -944,10 +806,6 @@ public partial class SwarmAssistentExtension
{
outRow["krea_likely"] = true;
}
if (fromDisk["has_card"]?.Value<bool>() == true)
{
outRow["has_card"] = true;
}
return outRow;
}
+3 -5
View File
@@ -138,16 +138,14 @@ public sealed class AssistentConfig
return
[
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
"actions", "search_query", "civitai_query",
"actions", "generate", "ask",
"use_init_image", "clear_init_image", "init_creativity", "denoise",
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
"creativity", "intensity", "complexity", "movement",
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
"memory_query", "memory_kind", "tag_query", "user_prefs",
"inventory_query", "skills", "persona_shelves", "persona_clone", "persona", "controls",
"variants",
"clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls",
"inventory_query", "variants",
];
}
+1 -628
View File
@@ -2,22 +2,16 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using FreneticUtilities.FreneticExtensions;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Core;
using SwarmUI.Text2Image;
using SwarmUI.Utils;
using SwarmUI.WebAPI;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Server-side model inventory, assistant cards and Civitai lookups.</summary>
/// <summary>Server-side model inventory (LoRA / checkpoint / wildcard lists).</summary>
public partial class SwarmAssistentExtension
{
const int MaxLorasInInventoryFallback = 150;
@@ -25,444 +19,6 @@ public partial class SwarmAssistentExtension
const int MaxCheckpointsInInventoryFallback = 60;
const int InventoryBlurbMaxFallback = 140;
static string ModelWeightPath(string setName, string modelName)
{
if (string.IsNullOrWhiteSpace(modelName) || !Program.T2IModelSets.TryGetValue(setName, out T2IModelHandler handler))
{
return null;
}
if (!handler.Models.TryGetValue(modelName, out T2IModel model) && !handler.Models.TryGetValue(modelName.Replace('\\', '/'), out model))
{
// Try suffix match
model = handler.Models.Values.FirstOrDefault(m =>
string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase)
|| m.Name.EndsWith("/" + modelName, StringComparison.OrdinalIgnoreCase)
|| Path.GetFileNameWithoutExtension(m.Name) == Path.GetFileNameWithoutExtension(modelName));
}
if (model is null)
{
return null;
}
try
{
// SwarmUI T2IModel exposes RawFilePath in recent builds.
return model.RawFilePath;
}
catch
{
return null;
}
}
static string CardPathForWeight(string weightPath)
{
if (string.IsNullOrWhiteSpace(weightPath))
{
return null;
}
string dir = Path.GetDirectoryName(weightPath);
string stem = Path.GetFileNameWithoutExtension(weightPath);
if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(stem))
{
return null;
}
return Path.Combine(dir, $"{stem}.assistent.json");
}
static string SetNameForKind(string kind)
{
return (kind ?? "").Trim().ToLowerInvariant() switch
{
"lora" => "LoRA",
"checkpoint" or "ckpt" or "stable-diffusion" => "Stable-Diffusion",
_ => null,
};
}
JObject ReadCardObject(string kind, string name)
{
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
string card = CardPathForWeight(weight);
if (card is null || !File.Exists(card))
{
return null;
}
try
{
return JObject.Parse(File.ReadAllText(card, Encoding.UTF8));
}
catch
{
return null;
}
}
public async Task<JObject> AssistentGetCard(Session session, string kind, string name)
{
await Task.CompletedTask;
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
{
return new JObject { ["error"] = "kind and name required" };
}
JObject card = ReadCardObject(kind, name);
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
return new JObject
{
["success"] = true,
["kind"] = kind,
["name"] = name,
["has_card"] = card is not null,
["weight_path"] = weight,
["card"] = card,
};
}
public async Task<JObject> AssistentSaveCard(Session session, string kind, string name, JObject card, bool enqueue_wanted = false)
{
await Task.CompletedTask;
if (card is null)
{
return new JObject { ["error"] = "card required" };
}
kind = (kind ?? card["kind"]?.ToString() ?? "").Trim();
name = (name ?? card["name"]?.ToString() ?? "").Trim();
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
{
return new JObject { ["error"] = "kind and name required" };
}
card["kind"] = kind;
card["name"] = name;
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
if (!string.IsNullOrWhiteSpace(weight) && File.Exists(weight))
{
string path = CardPathForWeight(weight);
File.WriteAllText(path, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
_ = IngestCardToMemory(card, name);
return new JObject { ["success"] = true, ["path"] = path, ["installed"] = true };
}
// Not installed — draft into wanted-cards + optionally enqueue download for next up.
Directory.CreateDirectory(WantedCardsDir());
string rawVid = card["version_id"]?.ToString() ?? "draft";
string vid = Regex.IsMatch(rawVid, @"^\d+$") ? rawVid : "draft";
string draft = Path.Combine(WantedCardsDir(), $"{vid}.assistent.json");
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
if (enqueue_wanted || !string.IsNullOrWhiteSpace(card["civitai_url"]?.ToString()))
{
await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value<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, AssistentMemory.SharedPersona);
}
catch (Exception ex)
{
Logs.Debug($"IngestCardToMemory: {ex.Message}");
}
}
public async Task<JObject> AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0, bool fetch = false)
{
string set = SetNameForKind(kind);
string weight = ModelWeightPath(set, name);
JObject civitai = null;
JArray exampleUrls = [];
JArray previewUrls = [];
bool hasSidecar = false;
string fetchError = null;
bool fetched = false;
if (!string.IsNullOrWhiteSpace(weight))
{
string stem = Path.GetFileNameWithoutExtension(weight);
string dir = Path.GetDirectoryName(weight);
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
if (File.Exists(side))
{
hasSidecar = true;
try
{
civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8));
}
catch
{
// ignore
}
}
foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
{
string prev = Path.Combine(dir ?? "", stem + suffix);
if (File.Exists(prev))
{
// Swarm View path — relative URL works in the same origin browser session.
previewUrls.Add($"View/Models/{(kind == "lora" ? "Lora" : "Stable-Diffusion")}/{Path.GetFileName(prev)}");
break;
}
}
}
if (civitai is not null)
{
if (version_id <= 0)
{
version_id = civitai["id"]?.Value<int?>() ?? 0;
}
CollectExampleUrls(civitai, exampleUrls);
}
string hash = null;
string trigger = null;
try
{
if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h)
&& (h.Models.TryGetValue(name, out T2IModel m)
|| h.Models.TryGetValue(name.Replace('\\', '/'), out m)))
{
trigger = m.Metadata?.TriggerPhrase;
hash = m.Metadata?.Hash;
}
}
catch
{
// ignore
}
if (fetch && civitai is null)
{
string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
if (string.IsNullOrWhiteSpace(apiKey))
{
fetchError = "Civitai: нет ключа в User Settings";
}
else
{
try
{
JObject remote = null;
if (version_id > 0)
{
remote = await FetchCivitaiModelVersion(apiKey, version_id);
}
if (remote is null && !string.IsNullOrWhiteSpace(hash))
{
string sha = hash.Trim().ToLowerInvariant();
if (sha.StartsWith("sha256:"))
{
sha = sha["sha256:".Length..];
}
if (sha.Length == 64)
{
remote = await FetchCivitaiByHash(apiKey, sha);
}
else
{
fetchError ??= "Civitai: хеш модели не SHA256";
}
}
if (remote is not null)
{
civitai = remote;
fetched = true;
version_id = remote["id"]?.Value<int?>() ?? version_id;
CollectExampleUrls(remote, exampleUrls);
if (!string.IsNullOrWhiteSpace(weight))
{
try
{
string stem = Path.GetFileNameWithoutExtension(weight);
string dir = Path.GetDirectoryName(weight);
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
File.WriteAllText(side, remote.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
hasSidecar = true;
}
catch (Exception ex)
{
Logs.Debug($"AssistentGetCardMeta write sidecar: {ex.Message}");
}
}
}
else if (fetchError is null)
{
fetchError = string.IsNullOrWhiteSpace(hash)
? "Civitai: нет hash и version_id"
: "Хеш не найден на Civitai";
}
}
catch (Exception ex)
{
fetchError = $"Civitai: {ex.Message}";
}
}
}
JObject card = ReadCardObject(kind, name);
return new JObject
{
["success"] = true,
["kind"] = kind,
["name"] = name,
["version_id"] = version_id,
["trigger_phrase"] = trigger,
["has_card"] = card is not null,
["has_sidecar"] = hasSidecar,
["fetched"] = fetched,
["fetch_error"] = fetchError,
["card"] = card,
["civitai"] = civitai,
["example_urls"] = exampleUrls,
["preview_urls"] = previewUrls,
["weight_path"] = weight,
["hash"] = hash,
};
}
static void CollectExampleUrls(JObject civitai, JArray exampleUrls)
{
if (civitai?["images"] is not JArray imgs)
{
return;
}
foreach (JToken img in imgs.Take(6))
{
string u = img?["url"]?.ToString();
if (!string.IsNullOrWhiteSpace(u))
{
exampleUrls.Add(u);
}
}
}
async Task<JObject> FetchCivitaiByHash(string apiKey, string sha)
{
string[] hosts = ["civitai.red", "civitai.com"];
Exception last = null;
foreach (string host in hosts)
{
try
{
string url = $"https://{host}/api/v1/model-versions/by-hash/{sha}";
using HttpRequestMessage req = new(HttpMethod.Get, url);
if (!string.IsNullOrWhiteSpace(apiKey))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
}
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
{
continue;
}
if (!resp.IsSuccessStatusCode)
{
last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
if ((int)resp.StatusCode is 401 or 403)
{
throw last;
}
continue;
}
return JObject.Parse(body);
}
catch (Exception ex) when (ex is not HttpRequestException && ex.Message.Contains("401"))
{
throw;
}
catch (Exception ex)
{
last = ex;
}
}
if (last is not null)
{
throw last;
}
return null;
}
async Task<JObject> FetchCivitaiModelVersion(string apiKey, int versionId)
{
string[] hosts = ["civitai.red", "civitai.com"];
Exception last = null;
foreach (string host in hosts)
{
try
{
string url = $"https://{host}/api/v1/model-versions/{versionId}";
using HttpRequestMessage req = new(HttpMethod.Get, url);
if (!string.IsNullOrWhiteSpace(apiKey))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
}
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
if ((int)resp.StatusCode is 401 or 403)
{
throw last;
}
continue;
}
return JObject.Parse(body);
}
catch (Exception ex)
{
last = ex;
if (ex.Message.Contains("401") || ex.Message.Contains("403"))
{
throw;
}
}
}
if (last is not null)
{
throw last;
}
return null;
}
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).</summary>
public async Task<JObject> AssistentListInventory(Session session, bool rescan = false)
@@ -553,8 +109,6 @@ public partial class SwarmAssistentExtension
{
string weight = null;
try { weight = model.RawFilePath; } catch { /* ignore */ }
string cardPath = CardPathForWeight(weight);
bool hasCard = !string.IsNullOrWhiteSpace(cardPath) && File.Exists(cardPath);
string usage = model.Metadata?.UsageHint;
string desc = model.Metadata?.Description;
@@ -571,30 +125,11 @@ public partial class SwarmAssistentExtension
}
string blurb = null;
if (hasCard)
{
try
{
JObject card = JObject.Parse(File.ReadAllText(cardPath, Encoding.UTF8));
string fromCard = (card["notes"] ?? card["when"] ?? card["prompt_hint"])?.ToString();
if (!string.IsNullOrWhiteSpace(fromCard))
{
blurb = Clip(fromCard.Trim(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
}
}
catch
{
// ignore bad card json
}
}
if (string.IsNullOrWhiteSpace(blurb))
{
string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
if (!string.IsNullOrWhiteSpace(raw))
{
blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
}
}
JArray tags = null;
if (model.Metadata?.Tags is { Length: > 0 } tagArr)
@@ -618,7 +153,6 @@ public partial class SwarmAssistentExtension
["architecture"] = model.ModelClass?.ID,
["compat_class"] = model.ModelClass?.CompatClass?.ID,
["hash"] = model.Metadata?.Hash ?? "",
["has_card"] = hasCard,
["krea_likely"] = LooksLikeKreaArch(model),
};
if (!string.IsNullOrWhiteSpace(weight))
@@ -665,165 +199,4 @@ public partial class SwarmAssistentExtension
}
return entry;
}
/// <summary>Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key.</summary>
public async Task<JObject> AssistentSearchCivitai(Session session, string query, int limit = 8)
{
string q = (query ?? "").Trim();
if (string.IsNullOrWhiteSpace(q))
{
return new JObject { ["error"] = "query is required" };
}
limit = Math.Clamp(limit, 1, 20);
string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
HashSet<string> installedNames = CollectInstalledLoraNames();
HashSet<string> installedHashes = CollectInstalledLoraHashes();
string[] hosts = ["civitai.red", "civitai.com"];
Exception lastEx = null;
foreach (string host in hosts)
{
try
{
string url = $"https://{host}/api/v1/models?limit={limit}&types=LORA&query={Uri.EscapeDataString(q)}";
using HttpRequestMessage req = new(HttpMethod.Get, url);
if (!string.IsNullOrWhiteSpace(apiKey))
{
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
}
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
lastEx = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 200)}");
continue;
}
JObject parsed = JObject.Parse(body);
JArray items = parsed["items"] as JArray ?? [];
JArray results = [];
foreach (JToken item in items)
{
if (item is not JObject mo)
{
continue;
}
JObject card = BuildCivitaiCard(mo, installedNames, installedHashes);
if (card is not null)
{
results.Add(card);
}
}
// Prefer Krea-compatible first
JArray sorted = new(results.OrderByDescending(t => LooksLikeKrea(t["base_model"]?.ToString())).ThenBy(t => t["name"]?.ToString()));
return new JObject
{
["success"] = true,
["query"] = q,
["host"] = host,
["results"] = sorted,
["has_civitai_key"] = !string.IsNullOrWhiteSpace(apiKey),
};
}
catch (Exception ex)
{
lastEx = ex;
}
}
return new JObject { ["error"] = $"Civitai search failed: {lastEx?.Message ?? "unknown"}" };
}
static bool LooksLikeKrea(string text) => !string.IsNullOrEmpty(text) && Regex.IsMatch(text, @"krea", RegexOptions.IgnoreCase);
static HashSet<string> CollectInstalledLoraNames()
{
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
{
return names;
}
foreach (T2IModel m in handler.Models.Values)
{
names.Add(m.Name);
string leaf = m.Name.Replace('\\', '/').AfterLast('/');
if (!string.IsNullOrEmpty(leaf))
{
names.Add(leaf);
names.Add(Path.GetFileNameWithoutExtension(leaf));
}
}
return names;
}
static HashSet<string> CollectInstalledLoraHashes()
{
HashSet<string> hashes = new(StringComparer.OrdinalIgnoreCase);
if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
{
return hashes;
}
foreach (T2IModel m in handler.Models.Values)
{
string h = m.Metadata?.Hash;
if (!string.IsNullOrWhiteSpace(h))
{
hashes.Add(h.Trim().ToLowerInvariant());
}
}
return hashes;
}
static JObject BuildCivitaiCard(JObject model, HashSet<string> installedNames, HashSet<string> installedHashes)
{
string name = model["name"]?.ToString() ?? "";
JArray versions = model["modelVersions"] as JArray;
JObject ver = versions?.FirstOrDefault() as JObject;
if (ver is null)
{
return null;
}
string baseModel = ver["baseModel"]?.ToString() ?? "";
JArray trained = ver["trainedWords"] as JArray ?? [];
List<string> triggers = trained.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).Take(8).ToList();
JObject file = null;
foreach (JToken f in ver["files"] as JArray ?? [])
{
if (f is JObject fo && (fo["primary"]?.Value<bool>() == true || (fo["name"]?.ToString() ?? "").EndsWith(".safetensors", StringComparison.OrdinalIgnoreCase)))
{
file = fo;
break;
}
}
file ??= (ver["files"] as JArray)?.FirstOrDefault() as JObject;
string downloadUrl = file?["downloadUrl"]?.ToString() ?? ver["downloadUrl"]?.ToString() ?? "";
string fileName = file?["name"]?.ToString() ?? "";
string sha = file?["hashes"]?["SHA256"]?.ToString() ?? file?["hashes"]?["AutoV2"]?.ToString() ?? "";
string saveName = string.IsNullOrWhiteSpace(fileName)
? Regex.Replace(name, @"[^\w\-.]+", "_").Trim('_')
: Path.GetFileNameWithoutExtension(fileName);
bool already = false;
if (!string.IsNullOrWhiteSpace(sha) && installedHashes.Contains(sha.Trim().ToLowerInvariant()))
{
already = true;
}
else if (installedNames.Contains(saveName) || installedNames.Contains(name) || installedNames.Contains(fileName))
{
already = true;
}
return new JObject
{
["id"] = model["id"],
["version_id"] = ver["id"],
["name"] = name,
["base_model"] = baseModel,
["krea_likely"] = LooksLikeKrea(baseModel),
["triggers"] = new JArray(triggers),
["download_url"] = downloadUrl,
["file_name"] = saveName,
["sha256"] = sha,
["already_installed"] = already,
["n_sfw"] = model["nsfw"]?.Value<bool>() ?? false,
};
}
}
+1 -43
View File
@@ -1,8 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
@@ -10,7 +8,7 @@ using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Read/write routes for the vector memory list in ⚙ and the gpu-rent wanted queue badge.</summary>
/// <summary>Read/write routes for the vector memory list in ⚙.</summary>
public partial class SwarmAssistentExtension
{
/// <summary>Embed model the UI should use: settings overlay wins, then persona assistant.json.</summary>
@@ -274,44 +272,4 @@ public partial class SwarmAssistentExtension
return new JObject { ["error"] = $"memory clear: {ex.Message}" };
}
}
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
public async Task<JObject> AssistentListWanted(Session session)
{
await Task.CompletedTask;
string path = WantedModelsPath();
JArray items = [];
if (!File.Exists(path))
{
return new JObject { ["success"] = true, ["count"] = 0, ["items"] = items, ["path"] = path };
}
try
{
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.ReadAllText(path, Encoding.UTF8));
foreach ((string kind, List<WantedEntry> list) in sections.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
{
foreach (WantedEntry entry in list)
{
items.Add(new JObject
{
["kind"] = kind,
["url"] = entry.Url,
["title"] = entry.Title,
["version_id"] = entry.VersionId,
});
}
}
return new JObject
{
["success"] = true,
["count"] = items.Count,
["items"] = items,
["path"] = path,
};
}
catch (Exception ex)
{
return new JObject { ["error"] = $"wanted queue: {ex.Message}" };
}
}
}
+33 -6
View File
@@ -297,7 +297,22 @@ public partial class SwarmAssistentExtension
return null;
}
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
/// <summary>Extract Ollama prompt token count when present.</summary>
static int? ReadPromptEvalCount(JObject raw)
{
if (raw is null)
{
return null;
}
int? n = raw["prompt_eval_count"]?.Value<int?>();
if (n is null || n <= 0)
{
n = raw["promptEvalCount"]?.Value<int?>();
}
return n is > 0 ? n : null;
}
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional ask hops.</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, out JArray skills);
@@ -314,7 +329,7 @@ public partial class SwarmAssistentExtension
{
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
return new JObject
JObject result = new()
{
["success"] = true,
["reply"] = reply,
@@ -326,6 +341,12 @@ public partial class SwarmAssistentExtension
["system_chars"] = systemChars,
["system_layers"] = systemLayers,
};
int? promptEval = ReadPromptEvalCount(parsed);
if (promptEval is not null)
{
result["prompt_eval_count"] = promptEval.Value;
}
return result;
}
catch (Exception ex)
{
@@ -333,7 +354,7 @@ public partial class SwarmAssistentExtension
}
}
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
/// <summary>WebSocket streaming chat (Ollama stream:true) + ask 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, out JArray skills);
@@ -372,13 +393,13 @@ public partial class SwarmAssistentExtension
{
["clear_stream"] = true,
["hop"] = hop + 1,
["notice"] = "Civitai search done — refining…",
["notice"] = "Ask hop — refining…",
}, API.WebsocketTimeout);
}
}
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops(
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
await ws.SendJson(new JObject
JObject done = new()
{
["success"] = true,
["done"] = true,
@@ -390,7 +411,13 @@ public partial class SwarmAssistentExtension
["civitai_results"] = civitai,
["system_chars"] = systemChars,
["system_layers"] = systemLayers,
}, API.WebsocketTimeout);
};
int? promptEval = ReadPromptEvalCount(parsed);
if (promptEval is not null)
{
done["prompt_eval_count"] = promptEval.Value;
}
await ws.SendJson(done, API.WebsocketTimeout);
}
catch (Exception ex)
{
+65 -119
View File
@@ -46,28 +46,25 @@ public partial class SwarmAssistentExtension
patch["look_at"] = patch["vision_slots"];
}
}
if (ActionsContain(patch, "generate"))
{
patch["generate"] = true;
}
if (patch["ask"] is JValue askVal && askVal.Type == JTokenType.String)
{
string one = askVal.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(one))
{
patch["ask"] = new JArray(one);
}
else
{
patch.Remove("ask");
}
}
return patch;
}
static bool LooksLikeCardObject(JObject obj)
{
if (obj is null)
{
return false;
}
bool cardish = HasValue(obj, "kind") || HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint");
bool genish = HasValue(obj, "prompt") || HasValue(obj, "negative") || HasValue(obj, "loras") || HasValue(obj, "actions")
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "steps") || HasValue(obj, "cfg")
|| HasValue(obj, "aspect") || HasValue(obj, "seed") || HasValue(obj, "search_query")
|| HasValue(obj, "civitai_query") || HasValue(obj, "look_at") || HasValue(obj, "controls");
if (cardish && !genish && (HasValue(obj, "name") || HasValue(obj, "triggers") || HasValue(obj, "when")))
{
return true;
}
return HasValue(obj, "kind") && HasValue(obj, "name")
&& (HasValue(obj, "triggers") || HasValue(obj, "when") || HasValue(obj, "prompt_hint") || HasValue(obj, "notes"));
}
JObject TryParsePatch(string reply)
{
if (string.IsNullOrWhiteSpace(reply))
@@ -82,7 +79,7 @@ public partial class SwarmAssistentExtension
try
{
JObject obj = JObject.Parse(raw);
if (obj is null || LooksLikeCardObject(obj))
if (obj is null)
{
continue;
}
@@ -90,7 +87,7 @@ public partial class SwarmAssistentExtension
{
JObject normalized = NormalizePatch(obj);
lastAny = normalized;
if (FenceIsTerminalPatch(obj))
if (FenceIsTerminalPatch(normalized))
{
lastTerminal = normalized;
}
@@ -105,10 +102,9 @@ public partial class SwarmAssistentExtension
}
/// <summary>
/// If the reply already contains a closed fenced patch/card that is "done enough" to act on,
/// If the reply already contains a closed fenced patch that is "done enough" to act on,
/// cut everything after it. Do NOT stop on weak fences (pack/creativity/notes-only) — models
/// often emit a tiny JSON first then the real prompt fence; aborting early cuts the prompt
/// and blocks skill_load / generate.
/// often emit a tiny JSON first then the real prompt fence; aborting early cuts the prompt.
/// </summary>
static bool TryTruncateAtCompleteFence(string reply, out string truncated)
{
@@ -128,7 +124,7 @@ public partial class SwarmAssistentExtension
string raw = match.Groups[1].Value.Trim();
try
{
JObject obj = JObject.Parse(raw);
JObject obj = NormalizePatch(JObject.Parse(raw));
if (obj is null || !FenceIsTerminalPatch(obj))
{
continue;
@@ -145,7 +141,7 @@ public partial class SwarmAssistentExtension
}
/// <summary>
/// True when a closed fence is worth aborting the Ollama stream (real deliverable or tool hop).
/// True when a closed fence is worth aborting the Ollama stream (real deliverable or ask hop).
/// </summary>
static bool FenceIsTerminalPatch(JObject obj)
{
@@ -153,7 +149,11 @@ public partial class SwarmAssistentExtension
{
return false;
}
if (LooksLikeCardObject(obj))
if (obj["generate"]?.Type == JTokenType.Boolean && obj["generate"].Value<bool>())
{
return true;
}
if (HasAsk(obj))
{
return true;
}
@@ -165,50 +165,18 @@ public partial class SwarmAssistentExtension
{
return true;
}
if (HasValue(obj, "search_query") || HasValue(obj, "civitai_query"))
if (ActionsContain(obj, "generate"))
{
return true;
}
if (HasValue(obj, "memory_query") || HasValue(obj, "tag_query") || HasValue(obj, "inventory_query"))
{
return true;
}
if (obj["actions"] is JArray acts)
{
foreach (JToken a in acts)
{
string s = a?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(s))
{
continue;
}
if (s.Equals("skill_load", StringComparison.OrdinalIgnoreCase)
|| s.Equals("persona_read", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_get", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_search", StringComparison.OrdinalIgnoreCase)
|| s.Equals("heard_search", StringComparison.OrdinalIgnoreCase)
|| s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase)
|| s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase)
|| s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase)
|| s.Equals("interrupt", StringComparison.OrdinalIgnoreCase)
|| s.Equals("generate", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_upsert", StringComparison.OrdinalIgnoreCase)
|| s.Equals("user_pref_upsert", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
string prompt = obj["prompt"]?.ToString()?.Trim() ?? "";
if (prompt.Length >= 48)
{
return true;
}
// Real param change without prose notes
if (HasValue(obj, "loras") || HasValue(obj, "aspect") || HasValue(obj, "steps")
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "cfg")
|| HasValue(obj, "seed") || HasValue(obj, "controls")
|| HasValue(obj, "memories") || HasValue(obj, "user_prefs"))
|| HasValue(obj, "seed") || HasValue(obj, "controls"))
{
return true;
}
@@ -216,14 +184,40 @@ public partial class SwarmAssistentExtension
return false;
}
static string ExtractSearchQuery(JObject patch)
static bool HasAsk(JObject patch)
{
if (patch is null)
if (patch?["ask"] is JArray asks)
{
return null;
foreach (JToken t in asks)
{
if (!string.IsNullOrWhiteSpace(t?.ToString()))
{
return true;
}
string q = (patch["search_query"] ?? patch["civitai_query"])?.ToString()?.Trim();
return string.IsNullOrWhiteSpace(q) ? null : q;
}
return false;
}
return !string.IsNullOrWhiteSpace(patch?["ask"]?.ToString());
}
static bool AskContains(JObject patch, string name)
{
if (patch is null || string.IsNullOrWhiteSpace(name))
{
return false;
}
if (patch["ask"] is JArray asks)
{
foreach (JToken t in asks)
{
if (string.Equals(t?.ToString()?.Trim(), name, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
return string.Equals(patch["ask"]?.ToString()?.Trim(), name, StringComparison.OrdinalIgnoreCase);
}
static bool ActionsContain(JObject patch, string action)
@@ -242,26 +236,7 @@ public partial class SwarmAssistentExtension
return false;
}
static string ExtractMemoryQuery(JObject patch)
{
string q = patch?["memory_query"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(q))
{
return q;
}
return ActionsContain(patch, "memory_search") ? ExtractSearchQuery(patch) : null;
}
static string ExtractTagQuery(JObject patch)
{
string q = patch?["tag_query"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(q))
{
return q;
}
return ActionsContain(patch, "lookup_tags") ? ExtractSearchQuery(patch) : null;
}
/// <summary>Server tool hops are ask-only: settings dump or truncated inventory.</summary>
static string NextToolHop(JObject patch, HashSet<string> skip = null)
{
if (patch is null)
@@ -269,42 +244,13 @@ public partial class SwarmAssistentExtension
return null;
}
bool Skip(string tool) => skip is not null && skip.Contains(tool);
if (ActionsContain(patch, "memory_get") && !Skip("memory_get"))
if (AskContains(patch, "settings") && !Skip("ask_settings"))
{
return "memory_get";
return "ask_settings";
}
if ((ActionsContain(patch, "memory_search") || !string.IsNullOrWhiteSpace(patch["memory_query"]?.ToString()))
&& !Skip("memory_search"))
if (AskContains(patch, "inventory") && !Skip("ask_inventory"))
{
return "memory_search";
}
if ((ActionsContain(patch, "heard_search") || string.Equals(patch["heard_query"]?.ToString(), "1", StringComparison.Ordinal))
&& !Skip("heard_search"))
{
return "heard_search";
}
if ((ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString()))
&& !Skip("lookup_tags"))
{
return "lookup_tags";
}
if ((ActionsContain(patch, "list_inventory") || !string.IsNullOrWhiteSpace(patch["inventory_query"]?.ToString()))
&& !Skip("list_inventory"))
{
return "list_inventory";
}
if (ActionsContain(patch, "skill_load") && !Skip("skill_load"))
{
return "skill_load";
}
if (ActionsContain(patch, "persona_read") && !Skip("persona_read"))
{
return "persona_read";
}
if ((ActionsContain(patch, "search_civitai") || !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
&& !Skip("civitai"))
{
return "civitai";
return "ask_inventory";
}
return null;
}
+4 -1
View File
@@ -190,7 +190,10 @@ public partial class SwarmAssistentExtension
if (assistant is not null && assistant.Count > 0)
{
JObject sparse = new();
foreach (string key in new[] { "num_ctx", "history_keep_turns", "memory_top_k", "user_prefs_weight", "user_prefs_max" })
foreach (string key in new[] {
"num_ctx", "history_keep_turns", "memory_top_k", "user_prefs_weight", "user_prefs_max",
"compress_at", "chars_per_token", "compress_auto", "num_predict",
})
{
if (assistant[key] is not null)
{
-189
View File
@@ -1,189 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Queue of models the assistant wants downloaded (merged into gpu-rent models.yaml on next up/capture).</summary>
public partial class SwarmAssistentExtension
{
static readonly object WantedFileLock = new();
string WantedModelsPath() => Path.Combine(DataRoot(), ".gpu-rent-wanted-models.yaml");
string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards");
public async Task<JObject> AssistentEnqueueWanted(Session session, string kind, string url, int version_id = 0, string title = null, JObject card = null)
{
await Task.CompletedTask;
kind = (kind ?? "lora").Trim().ToLowerInvariant();
if (kind is not ("lora" or "checkpoint" or "vae" or "embedding" or "controlnet" or "upscaler" or "clip"))
{
kind = "lora";
}
url = (url ?? "").Trim();
if (string.IsNullOrWhiteSpace(url) && version_id > 0)
{
url = $"https://civitai.red/models/0?modelVersionId={version_id}";
}
if (string.IsNullOrWhiteSpace(url))
{
return new JObject { ["error"] = "url or version_id required" };
}
if (version_id <= 0)
{
Match m = Regex.Match(url, @"modelVersionId=(\d+)", RegexOptions.IgnoreCase);
if (m.Success)
{
version_id = int.Parse(m.Groups[1].Value);
}
}
string path = WantedModelsPath();
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? DataRoot());
lock (WantedFileLock)
{
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : "");
if (version_id > 0)
{
foreach (List<WantedEntry> list in sections.Values)
{
if (list.Any(e => e.VersionId == version_id))
{
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path, ["version_id"] = version_id };
}
}
}
else
{
foreach (List<WantedEntry> list in sections.Values)
{
if (list.Any(e => string.Equals(e.Url, url, StringComparison.OrdinalIgnoreCase)))
{
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path };
}
}
}
if (!sections.TryGetValue(kind, out List<WantedEntry> bucket))
{
bucket = [];
sections[kind] = bucket;
}
bucket.Add(new WantedEntry { Url = url, Title = title, VersionId = version_id });
File.WriteAllText(path, WriteWantedYaml(sections), Encoding.UTF8);
}
if (card is not null && version_id > 0)
{
Directory.CreateDirectory(WantedCardsDir());
string draft = Path.Combine(WantedCardsDir(), $"{version_id}.assistent.json");
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
}
return new JObject { ["success"] = true, ["path"] = path, ["version_id"] = version_id };
}
sealed class WantedEntry
{
public string Url;
public string Title;
public int VersionId;
}
static Dictionary<string, List<WantedEntry>> LoadWantedYaml(string raw)
{
Dictionary<string, List<WantedEntry>> sections = new(StringComparer.OrdinalIgnoreCase);
string currentKind = null;
WantedEntry cur = null;
void Flush()
{
if (cur is null || string.IsNullOrWhiteSpace(cur.Url) || string.IsNullOrWhiteSpace(currentKind))
{
cur = null;
return;
}
if (!sections.TryGetValue(currentKind, out List<WantedEntry> list))
{
list = [];
sections[currentKind] = list;
}
list.Add(cur);
cur = null;
}
foreach (string line in (raw ?? "").Split('\n'))
{
string t = line.TrimEnd();
if (string.IsNullOrWhiteSpace(t) || t.TrimStart().StartsWith('#'))
{
continue;
}
Match kindLine = Regex.Match(t, @"^([A-Za-z0-9_-]+):\s*$");
if (kindLine.Success && !t.TrimStart().StartsWith('-'))
{
Flush();
currentKind = kindLine.Groups[1].Value.Trim().ToLowerInvariant();
continue;
}
Match urlLine = Regex.Match(t, @"^\s*-\s*url:\s*[""']?(.+?)[""']?\s*$");
if (urlLine.Success)
{
Flush();
cur = new WantedEntry { Url = urlLine.Groups[1].Value.Trim() };
continue;
}
if (cur is null)
{
continue;
}
Match titleLine = Regex.Match(t, @"^\s*title:\s*[""']?(.+?)[""']?\s*$");
if (titleLine.Success)
{
cur.Title = titleLine.Groups[1].Value.Trim();
continue;
}
Match vidLine = Regex.Match(t, @"^\s*version_id:\s*(\d+)\s*$");
if (vidLine.Success && int.TryParse(vidLine.Groups[1].Value, out int vid))
{
cur.VersionId = vid;
}
}
Flush();
return sections;
}
static string WriteWantedYaml(Dictionary<string, List<WantedEntry>> sections)
{
StringBuilder sb = new();
sb.AppendLine("# Assistent wanted queue — merged into local models.yaml on gpu-rent up/capture");
string[] order = ["checkpoint", "lora", "vae", "embedding", "controlnet", "upscaler", "clip"];
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
foreach (string kind in order.Concat(sections.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)))
{
if (!seen.Add(kind) || !sections.TryGetValue(kind, out List<WantedEntry> list) || list.Count == 0)
{
continue;
}
sb.AppendLine($"{kind}:");
foreach (WantedEntry e in list)
{
sb.AppendLine($" - url: \"{e.Url.Replace("\"", "%22")}\"");
if (!string.IsNullOrWhiteSpace(e.Title))
{
sb.AppendLine($" title: \"{e.Title.Replace("\"", "'")}\"");
}
if (e.VersionId > 0)
{
sb.AppendLine($" version_id: {e.VersionId}");
}
}
}
return sb.ToString();
}
}
+5 -3
View File
@@ -1,7 +1,6 @@
{
"num_ctx": 16384,
"num_predict": 3072,
"max_civitai_hops": 2,
"max_loras_inventory": 150,
"max_checkpoints_inventory": 60,
"max_wildcards_inventory": 80,
@@ -19,7 +18,7 @@
"memory_min_score": 0.32,
"user_prefs_weight": 1.0,
"user_prefs_max": 16,
"max_tool_hops": 4,
"max_tool_hops": 2,
"tag_lookup_limit": 20,
"identity_always_shelves": ["persona", "voice", "rules", "likes", "dislikes"],
"memory_quotas": {
@@ -36,5 +35,8 @@
"keywords": ["krea"]
},
"context_prompt_max": 2000,
"history_keep_turns": 4
"history_keep_turns": 4,
"compress_at": 0.70,
"chars_per_token": 3.2,
"compress_auto": true
}
+25 -46
View File
@@ -10,84 +10,63 @@ When instructions conflict, apply this order (highest wins):
1. **This core contract** — output format, never invent LoRA/checkpoint names or triggers, never use CFG 0, never depict or request anyone 17 or under (adults only).
2. **Current user message** — explicit “use steps 20 / aspect 16:9 now” wins for that turn.
3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
4. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
4. **Chat session** (`## Live SwarmUI context` / session JSON) — current generation settings for **this chat** (prompt, params, LoRAs, board). Source of truth for Generate.
5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
6. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
7. **`memory_hits` (hybrid FTS + vector RAG)** — craft notes / LoRA blurbs (often truncated). Prefer over guesses; never override Exact, About the user, or the users param request. Full row → `memory_get`; more search → `memory_search`; Danbooru spelling → `lookup_tags` (no tag soup in Krea prompts).
8. Guesses — last resort only.
6. Guesses — last resort only.
Exact = defaults encyclopedia. About the user = human taste. RAG = soft craft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact (or `session_exact`) and the user did not ask to change them.
Exact = defaults encyclopedia. About the user = human taste. Session = what Generate will run. Soft craft notes may appear in context from the server — never override Exact, About the user, or the users param request.
Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. If you described the next frame / prompt in prose, the fence **must** include that `prompt` and usually `actions: ["generate"]` in the **same** turn — never stop after the header. **`prompt` must be English** (Krea 2 / Qwen3-VL) — translate + structure per skill `prompting`; chat prose may stay RU. Keep prose short (a few lines). **After the closing ``` of the JSON fence, STOP** — no «Готово!», no second fenced patch, no “сейчас сгенерирую оба” in prose. **One turn = one patch.** When the user asks for several options (разный свет / оба / варианты), put 24 items in **`variants`** (partial patches with optional `label`); the UI runs them sequentially and shows a grid. Do not emit two fences. Ordinary single-image requests stay one patch without `variants`. Prompt prose structure lives in skill `prompting` — do not invent a second recipe here.
**Sparse deltas only.** Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` / full `prompt` when they already match the session and the user did not ask to change them.
## Live context
Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. Chat prose may stay RU; **`prompt` must be English** (Krea 2). Keep prose short. **After the closing ``` of the JSON fence, STOP.** **One turn = one patch.** Several options → `variants` (24). Prompt structure lives in skill `prompting`.
"Live SwarmUI context" JSON is ground truth for this turn:
## Live context (chat session)
- Use only LoRA/checkpoint **names** from `selected_loras` / `available_loras` (or Civitai hop results). `selected_loras` = currently enabled. Prefer listed `triggers` / `trigger_phrase` / `blurb` — **never invent**.
- Rich entries (blurbs/triggers) are selected + top krea-likely. Name-only rows need `list_inventory` + `inventory_query` before you rely on them.
- `memory_hits` may be truncated (`truncated: true`) — use `memory_get` for the full text.
- `has_vision_image` true means a real board frame exists. `images_in_request` true means JPEG bytes are in **this** request. **Do not** `look_at` just because a frame exists. Emit `look_at` only when you cannot continue without pixels (user asked to look/critique/describe/compare, or a defect you cannot infer from the prompt). Never invent what the image looks like. A new Generate / «ещё» / prompt edit does **not** need vision.
- Prefer `krea_likely` / Krea architecture; ignore FLUX/SDXL. Respect current params unless asked or pack is `fix_params`.
- Init/inpaint flags and `image_slots` are in the JSON. Extra pack fields are documented in the active pack.
Compact session JSON is ground truth for this turn:
## Memory (short)
- Craft RAG write: `memory_upsert` / `memory_forget` + `memories: [{kind,key,text,scope}]` (default personal).
- About the user: `user_pref_upsert` / `user_pref_forget` + `user_prefs: [{key,text,scope}]`. Do **not** put human taste into craft `memories`.
- Fat memory skill text: `skill_load` + `skills: ["memory"]` when you need the full write/read playbook.
- `selected_loras` / checkpoint — use only names present there (or after `ask:["inventory"]`).
- Board: `board.has_generate`, ref ids — for `look_at` only when you need pixels.
- For full numeric/Exact dump: `"ask": ["settings"]`. For LoRA/ckpt list: `"ask": ["inventory"]`.
## Output contract (mandatory)
1. Short helpful reply in the user's language (RU or EN).
2. **Frame turns only:** one fenced JSON patch with **only fields you want to change**. Chat / Q&A / opinion / remember: **prose only — omit the JSON patch.**
2. **Only when changing settings or commanding generate/look/ask:** one fenced JSON with **only fields you want to change** (+ optional commands). Pure chat / Q&A / opinion: **prose only — omit the JSON.**
```json
{
"prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
"negative": "bad quality, worst quality",
"loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["listed_trigger"]}],
"aspect": "16:9",
"actions": ["generate"],
"notes": "one-line why"
"generate": true
}
```
Several options in one ask (still one fence):
Several options (still one fence):
```json
{
"prompt": "same subject base…",
"aspect": "16:9",
"actions": ["generate"],
"generate": true,
"variants": [
{ "label": "warm light", "prompt": "… warm window light …" },
{ "label": "cool light", "prompt": "… cool moonlight …" },
{ "label": "portrait 9:16", "aspect": "9:16" }
{ "label": "cool light", "prompt": "… cool moonlight …" }
]
}
```
### Patch rules
- Omit unchanged **params** (`steps`/`cfg`/`sigma_shift`/`aspect`). For Generate, still include **`negative`**: create if live is empty, lightly supplement if the scene needs a specific omit, or echo the live/Exact box unchanged — never drop it.
- Prefer omitting Exact-matching **`controls`** (e.g. do not re-emit `"horny": 35` / `"preference_bias": 0.35` when unchanged) — echoing defaults in a Generate patch resets the UI sliders.
- `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height.
- Optional keys (seed, vary, init/mask, creativity/sliders, pack, **controls**, persona authoring, search/memory queries, **`variants`**) — use when needed; packs list the ones for that mode.
- **`controls`** — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids.
- Omit unchanged params. Include `negative` when starting Generate if live/session negative is empty or the scene needs a specific omit.
- `loras` replaces the full intended set for this chat when you change LoRAs.
- Prefer `aspect` over raw width/height.
- Optional: seed, vary, init/mask, controls, pack, `variants`.
- Do not invent model or LoRA filenames.
### Actions / hops
### Commands
- `"generate"` — Apply + start generation when this turn is a **new/updated frame** (they described a shot, asked to draw/edit/«ещё», or clearly want to see a result). They do **not** have to type «генерируй». **Do not** emit `generate` for chat, opinions («нравится»), trivia, look/critique without a redraw, remember/save, describe_ref, Cards/authoring. Chat-only turns: prose, no JSON patch (or prompt-only without `actions`).
- If the user only asks to **remember / save** a prompt as base/template («запомни», «как базовый промпт», «шаблон») and did **not** ask for a new image: **omit** `actions: ["generate"]`, do **not** `look_at`. Prefer `memory_upsert` (kind `note`, key like `base_prompt`) or a short ack; you may echo `prompt` in the patch only to sync the SwarmUI box — the UI will not Generate on remember turns.
- `"search_civitai"` + **required** short `search_query` — Civitai hop (user Confirms downloads). Without `search_query` the hop is skipped (never search the whole user message).
- `"interrupt"` — stop generation.
- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
- `"skill_load"` + `skills: ["memory"]` — load fat skill text.
- `"persona_read"` — load lore shelves not in always-on identity (e.g. `roleplay` / `craft` / `humor` when NSFW tone or craft detail needs them).
- `"memory_upsert"` / `"memory_forget"` / `"user_pref_upsert"` / `"user_pref_forget"` — writes.
- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — `author_persona` only. Never `"persona_delete"`.
- `look_at: ["generate"|"ref1"|…]` — vision hop (JPEG arrives on the follow-up). Opt-in: user asked, or you truly need pixels. Do not pair `look_at` with `actions:["generate"]` on a normal write turn (that stares at the *old* frame and delays the new one).
- `"generate": true` — merge this delta into the chat session and run Generate (new/updated frame). **Omit** for chat, opinions, remember/save, look-only. Legacy `actions:["generate"]` is accepted as the same.
- If the user says not to generate / only remember / only answer — **omit** `generate` and do not look.
- `"look_at": ["generate"|"ref1"|…]` — vision hop (JPEG on follow-up). Use when you need pixels; do not pair with `generate` on the same normal write turn.
- `"ask": ["settings"]` — request full settings dump (Exact + all fields).
- `"ask": ["inventory"]` — request LoRA/checkpoint list.
- Pure Q&A: omit the JSON patch.
-7
View File
@@ -1,7 +0,0 @@
{
"id": "catalog_card",
"title": "Карточка модели",
"order": 70,
"aliases": ["card", "catalog"],
"prompt_file": "catalog_card.md"
}
-34
View File
@@ -1,34 +0,0 @@
# Mode: catalog_card
Goal: write a **recommendation card** for one checkpoint or LoRA so future Assistent turns know how to use it.
## Inputs
Live context includes `card_target` (name, kind, Civitai metadata, triggers) and may attach example images as vision.
## Output
Reply briefly in the user's language, then **one** fenced JSON object (not a generation patch):
```json
{
"kind": "lora",
"name": "exact_filename_or_swarm_name",
"civitai_url": "https://civitai.red/models/…?modelVersionId=…",
"version_id": 123,
"triggers": ["exact", "from", "metadata"],
"weight": 0.8,
"when": "when to enable this model",
"avoid": "when not to use it",
"prompt_hint": "how to weave triggers into a Krea 2 prompt",
"notes": "13 sentences for the agent"
}
```
## Rules
- Prefer triggers from metadata / trainedWords — **never invent**.
- `weight` typical 0.61.0 for LoRA; omit or 1.0 for checkpoints.
- Do **not** emit `actions: ["generate"]`. This mode does not start Generate.
- Do not invent other LoRAs. Stay on the single `card_target`.
- Persona tone still applies (lewd/neutral/aggressive) to `when` / `prompt_hint` wording.
+1 -1
View File
@@ -9,7 +9,7 @@ Goal: co-create a scene / moodboard direction for **Krea 2** (local Swarm).
- **Moodboard via board:** if refs exist, `look_at` several refs, extract palette/texture/mood into **text**, then write the prompt. Prefer text distillation over dumping refs as Prompt Images.
- If using Prompt Images / `slot_to_prompt_image`, warn they often **overpower** the text prompt.
- Suggest available LoRAs only from the live list, with triggers.
- Missing style LoRA → `search_civitai` + `search_query` (Krea-compatible).
- Missing style LoRA → ask the user or use `"ask": ["inventory"]` for names already on disk (Krea-compatible).
- Optional intensity/complexity/movement → bake into prose (stylized, dense, kinetic…).
## Deliverable
+9
View File
@@ -0,0 +1,9 @@
{
"id": "compress_history",
"title": "Compress history",
"order": 998,
"hidden": true,
"enabled": true,
"aliases": ["compress_history", "compress"],
"prompt_file": "compress_history.md"
}
+32
View File
@@ -0,0 +1,32 @@
# Mode: compress_history (hidden)
You compress older chat turns into a rolling memory for the next Assistent turns. This is not a generation turn and not a Q&A with the user.
## Hard rules
- Write in the **user's language** (match the dialogue).
- **No** fenced JSON. **No** `### JSON Patch`. **No** `actions`. **No** `generate`. **No** `look_at`. **No** tool hops.
- Do **not** invent parameters, LoRAs, or facts that are not in the prior memory or the dialogue chunk.
- Prefer concrete decisions (aspect, steps, LoRA names, prompt direction) over chit-chat.
- Keep the whole reply under ~600 words.
## Input
You receive:
1. Optional **previous conversation memory** (already compressed).
2. A chunk of **older user/assistant turns** that must be folded into memory.
3. Recent turns may be omitted — they stay as raw history.
## Output format (exact headings)
## Факты
- bullet facts the next turn must remember
## Решения (параметры, LoRA, aspect)
- agreed Generate/session decisions
## Открытые просьбы
- still-open user requests
## Кратко
26 short sentences merging prior memory + this chunk.
+12 -13
View File
@@ -1,31 +1,30 @@
# Mode: ordinary (комбайн)
Default all-rounder. Handle this turn from the user message + live context — do **not** wait for a specialized pack.
Default all-rounder. Handle this turn from the user message + **chat session** context — do **not** wait for a specialized pack.
## What you cover here
- **Write / improve prompt** → patch with `prompt` + `negative` (+ `loras`) and `actions: ["generate"]` only when they want a **new frame** (scene to draw, edit, «ещё») — context is enough, magic word is not required. Chat / «нравится» / Q&A → prose only, **no** generate. Do **not** `look_at` the last frame first. `negative` on Generate: create / supplement / echo live.
- **Light critique / improve last frame** → only when the user asks to look / critique / describe the picture. Then `look_at: ["generate"]` if `images_in_request` is false. Otherwise edit the prompt from text; `has_vision_image` alone is not a reason to look.
- **Scene / mood** → compose direction into the prompt (same patch rules).
- **Params** → only when they ask (steps/CFG/aspect/seed); omit Exact-matching numbers otherwise.
- **Inpaint / img2img** → set init/mask fields when they ask and flags allow; else say what is missing.
- **Describe a ref** → only with a real attached / look_at frame.
- **Write / improve prompt** → sparse JSON with only changed fields (`prompt`, optional `negative`/`loras`) and `"generate": true` when they want a **new frame**. Chat / «нравится» / Q&A → prose only, **no** JSON. Do **not** `look_at` the last frame first.
- **Light critique** → only when they ask to look / critique. Then `look_at: ["generate"]` if pixels are not already in the request.
- **Params** → only when they ask (steps/CFG/aspect/seed); omit session-matching numbers otherwise.
- **Inpaint / img2img** → set init/mask fields when they ask.
- Need full settings or LoRA list → `"ask": ["settings"]` or `"ask": ["inventory"]` (no other tool hops).
Prompt prose recipe = skill `prompting`. Creativity sliders = skill `creativity_sliders`.
## When to leave this mode
Emit `"pack": "<id>"` in the JSON patch only if the user clearly needs a dedicated workflow:
Emit `"pack": "<id>"` only if they clearly need a dedicated workflow:
- `critique_image` — deep frame critique loop
- `inpaint_edit` — regional edit / mask workflow
- `catalog_card` / `author_persona` Cards or persona authoring
- `author_persona` — persona authoring
- `describe_ref` — reverse-prompt a reference at length
Otherwise **stay in ordinary** and just do the work.
Otherwise **stay in ordinary**.
## Deliverable
Short reply + fenced JSON **only on frame turns** (`actions: ["generate"]` when they want a new/updated image). Chat/Q&A/opinion/remember: prose only — no fence.
«давай дальше» / next frame = new English `prompt` + `negative` (echo live if unchanged) + `actions:["generate"]` in the **same** turn — never leave an empty `### JSON Patch` header. Chat may be RU; **Generate `prompt` is always EN** (skill `prompting`).
Several options in one ask `variants` (24 partial patches with `label`); still one fence, still STOP after it.
Short reply + fenced JSON **only when changing session fields or commanding generate/look/ask**. Chat/Q&A: prose only.
«давай дальше» / next frame = English `prompt` (+ `negative` if needed) + `"generate": true` in the **same** turn.
Several options → `variants` (24); still one fence, still STOP after it.
+3 -5
View File
@@ -1,15 +1,13 @@
{
"keys": [
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
"actions", "search_query", "civitai_query",
"actions", "generate", "ask",
"use_init_image", "clear_init_image", "init_creativity", "denoise",
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
"creativity", "intensity", "complexity", "movement",
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
"memory_query", "memory_kind", "tag_query", "user_prefs",
"inventory_query", "skills", "persona_shelves", "persona_clone", "persona", "controls",
"variants"
"clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls",
"inventory_query", "variants"
]
}
+3 -2
View File
@@ -12,15 +12,16 @@ The **chat model** prepares the Generate-box text for **Krea 2** (Qwen3-VL). Do
5. **`negative` on every Generate** — create if live is empty (Exact `generation.negative`), supplement if the scene needs a specific omit, or echo live unchanged. Put “no blur / no people” ideas as positives in `prompt` instead of stuffing the negative box. Never clear `negative`.
6. Short ideas: expand into a full Krea paragraph. Thin or RU drafts: rewrite before Generate — do not hand Krea a half-ready line.
## Prep checklist (before `actions:["generate"]`)
## Prep checklist (before `"generate": true`)
- English only in `prompt`
- `negative` present (new / supplemented / echoed — not omitted)
- `negative` present when needed (new / supplemented / echoed — not omitted if live empty)
- Subject and action clear in the first sentence
- Wardrobe / body / setting concrete
- Camera + lighting present
- One coherent scene; NSFW stated in plain English if needed
- Triggers placed next to what they modify
- Only changed fields in the JSON — session already holds the rest
## Deliverable
+4 -7
View File
@@ -1,6 +1,6 @@
{
"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/new — новый чат\n/history — список чатов\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\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|ordinary|critique|compose|params|inpaint|describe|card|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone <id> — клон с указанной\n/persona save — записать согласованный черновик\n/civitai <query> — поиск LoRA (Confirm в чате)\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate, клик / Открыть / Enter — просмотр.\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nПри старте всегда новый чат; смена чата восстанавливает параметры.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).",
"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>У каждого чата свои параметры, LoRA, последний кадр и refs.</li><li>Чипсы aspect / seed / Vary / Turbo·RAW. В чате: <code>/help</code>.</li><li>Модель шлёт только дельту настроек + <code>generate</code>.</li></ul>Напиши, что сгенерировать — или кинь референс и попроси правку.",
"help_text": "Slash-команды (без LLM):\n/help — этот список\n/new — новый чат\n/history — список чатов\n/compress — сжать старые ходы в саммари (та же модель)\n/debug — сводка UI/Exact\n/debug ask · /why — сводка + короткий ответ модели\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|ordinary|critique|compose|params|inpaint|describe|persona\n/persona new — интервью: клон текущей личности (overlay)\n/persona clone <id> — клон с указанной\n/persona save — записать согласованный черновик\n/inventory — rescan моделей + обновить список LoRA\n\nНесколько вариантов в одном запросе («оба», разный свет) → патч с variants[] → сетка на Generate.\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nУ каждого чата свои параметры Generate; смена чата восстанавливает кадр и refs.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).\nЧип контекста в шапке чата показывает бюджет окна; клик — панель слоёв и ручное сжатие.",
"chips": [
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
@@ -19,9 +19,10 @@
{ "cmd": "/help", "hint": "список команд", "action": "help" },
{ "cmd": "/new", "hint": "новый чат", "action": "new" },
{ "cmd": "/history", "hint": "история чатов", "action": "history" },
{ "cmd": "/compress", "hint": "сжать старые ходы", "action": "compress" },
{ "cmd": "/debug", "hint": "сводка · ask = с LLM", "action": "debug" },
{ "cmd": "/why", "hint": "debug + пояснение LLM", "action": "why" },
{ "cmd": "/gen", "hint": "Generate сейчас", "action": "gen" },
{ "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" },
@@ -31,7 +32,6 @@
{ "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" },
{ "cmd": "/persona new", "hint": "клон / новая личность", "action": "persona_new" },
{ "cmd": "/persona clone ", "hint": "клон с id", "action": "persona_clone" },
@@ -55,9 +55,6 @@
"inpaint_edit": "inpaint_edit",
"describe": "describe_ref",
"describe_ref": "describe_ref",
"card": "catalog_card",
"catalog": "catalog_card",
"catalog_card": "catalog_card",
"persona": "author_persona",
"author": "author_persona",
"author_persona": "author_persona",
+6 -9
View File
@@ -4,6 +4,8 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
**Version 0.14.0****Чат = сессия генерации**: у каждого чата свои params/LoRA/checkpoint/кадр/refs; модель шлёт sparse-дельту + `generate`/`look_at`/`ask`; без вкладки Карточки и Civitai/wanted hops. **Сжатие контекста**: rolling-саммари той же Ollama-моделью, чип бюджета `N / num_ctx`, авто перед отправкой, `/compress`.
**Version 0.13.1** — Сборка 0.13.0: `using` для `WebSocket`/`HttpClient`, instance-методы с `Config`/`FilePath`, Sqlite dll рядом с extension (иначе вкладка не грузится / API пустые).
**Version 0.13.0****Реальный QLoRA-пайплайн**: `train_qlora.py` (TRL SFTTrainer + PEFT), HF-датасеты с маппингом (preset fiction title/tags→text), `max_samples`, полный post-train: safetensors → GGUF (`convert_lora_to_gguf.py`) → `ollama create` с `FROM ollama_base` + `ADAPTER`. Раннер: `builtin` + `custom`. Зависимости: `scripts/requirements-train.txt`.
@@ -150,10 +152,9 @@ Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
| `/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\|ordinary\|critique\|compose\|params\|inpaint\|describe\|card\|persona` | Switch pack |
| `/pack write\|ordinary\|critique\|compose\|params\|inpaint\|describe\|persona` | Switch pack |
| `/persona new\|clone\|save` | Overlay persona authoring |
| `/civitai <query>` | Ask LLM to search Civitai |
| `/inventory` | Rescan models + refresh LoRA list |
| `/inventory` | Rescan моделей + обновить список LoRA |
## Requirements
@@ -176,7 +177,7 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
## Packs & skills
**Packs** (one active): `ordinary` (default комбайн; covers write/critique/params flows), `write_prompt` (alias → same as ordinary), `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`, `author_persona`.
**Packs** (one active): `ordinary` (default комбайн; covers write/critique/params flows), `write_prompt` (alias → same as ordinary), `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `author_persona`.
Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client via `AssistentGetConfig.patch_keys`.
@@ -200,12 +201,8 @@ Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client v
| `AssistentSaveKnobs` | Overlay `_base/assistant.json` + Exact turbo/raw profiles |
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
| `AssistentListPersonas` | Persona catalog |
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
| `AssistentListUserPrefs` / `AssistentUpsertUserPref` / `AssistentForgetUserPref` / `AssistentClearUserPrefs` | About the user |
| `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + hops) |
| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + ask hops) |
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
+2 -8
View File
@@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT";
Version = "0.13.1";
Version = "0.14.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"];
}
@@ -47,11 +47,6 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentGetConfig, false, PermUse);
API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
API.RegisterAPICall(AssistentListInventory, false, PermUse);
API.RegisterAPICall(AssistentGetCard, false, PermUse);
API.RegisterAPICall(AssistentSaveCard, true, PermUse);
API.RegisterAPICall(AssistentEnqueueWanted, true, PermUse);
API.RegisterAPICall(AssistentGetCardMeta, false, PermUse);
API.RegisterAPICall(AssistentSearchCivitai, false, PermUse);
API.RegisterAPICall(AssistentChat, true, PermUse);
API.RegisterAPICall(AssistentChatWS, true, PermUse);
API.RegisterAPICall(AssistentListChats, false, PermUse);
@@ -68,7 +63,6 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentSearchMemory, false, PermUse);
API.RegisterAPICall(AssistentGetMemory, false, PermUse);
API.RegisterAPICall(AssistentLookupTags, false, PermUse);
API.RegisterAPICall(AssistentListWanted, false, PermUse);
API.RegisterAPICall(AssistentSaveControls, true, PermUse);
API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
API.RegisterAPICall(AssistentClonePersona, true, PermUse);
@@ -104,7 +98,7 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
Logs.Init("Swarm Assistent extension loaded (0.13.1 QLoRA pipeline)");
Logs.Init("Swarm Assistent extension loaded (0.14.0 chat session)");
}
int CfgInt(string key, int fallback)
+37 -53
View File
@@ -10,7 +10,6 @@
</div>
<div class="sa-app-tabs" role="tablist" aria-label="Разделы Assistent">
<button type="button" class="sa-app-tab sa-app-tab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
<button type="button" class="sa-app-tab" data-view="cards" id="sa_tab_cards" role="tab" aria-selected="false">Карточки</button>
<button type="button" class="sa-app-tab" data-view="train" id="sa_tab_train" role="tab" aria-selected="false">Обучение</button>
<button type="button" class="sa-app-tab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
</div>
@@ -61,6 +60,25 @@
</div>
</div>
<div class="sa-header-right">
<div class="sa-ctx-wrap" id="sa_ctx_wrap">
<button type="button" class="sa-ctx-chip sa-ctx-ok" id="sa_ctx_chip" title="Контекст модели" aria-expanded="false" aria-controls="sa_ctx_panel">
<span class="sa-ctx-dot" hidden aria-hidden="true"></span>
<span class="sa-ctx-chip-text">— / —</span>
</button>
<div class="sa-ctx-panel" id="sa_ctx_panel" hidden role="dialog" aria-label="Бюджет контекста">
<div class="sa-ctx-panel-head">
<strong>Контекст</strong>
<button type="button" class="basic-button sa-icon-btn" id="sa_ctx_close" title="Закрыть" aria-label="Закрыть">×</button>
</div>
<div class="sa-ctx-bar"><div class="sa-ctx-bar-fill" id="sa_ctx_bar_fill"></div></div>
<div id="sa_ctx_panel_body" class="sa-ctx-panel-body"></div>
<label class="sa-check sa-ctx-auto"><input type="checkbox" id="sa_ctx_auto" checked /> Авто перед отправкой</label>
<div class="sa-ctx-actions">
<button type="button" class="basic-button sa-primary" id="sa_ctx_compress">Сжать сейчас</button>
<button type="button" class="basic-button" id="sa_ctx_reset">Сбросить сжатие</button>
</div>
</div>
</div>
<div class="sa-persona-wrap">
<select id="sa_persona" class="sa-select" title="Характер / тон" aria-label="Характер">
<option value="neutral">Нейтральный</option>
@@ -82,7 +100,7 @@
<div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty" id="sa_chat_empty">
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону или открой <em>Карточки</em> для LoRA.</div>
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону — у каждого чата свои параметры Generate.</div>
</div>
</div>
<div class="sa-livebar" id="sa_livebar" hidden>
@@ -116,62 +134,26 @@
</section>
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="История чатов">
<div class="sa-chats-drawer-head">
<strong>Чаты</strong>
<span class="sa-chats-drawer-label">Чаты</span>
<div class="sa-chats-drawer-actions">
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">×</button>
<button type="button" class="sa-chats-icon-btn" id="sa_btn_new_chat" title="Новый чат" aria-label="Новый чат">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button type="button" class="sa-chats-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск по истории…" autocomplete="off" />
<div class="sa-chats-list" id="sa_chats_list"></div>
<div class="sa-chats-panel-hint">Клик по чату восстанавливает сообщения и параметры Generate.</div>
<div class="sa-chats-search-wrap">
<svg class="sa-chats-search-ico" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true"><circle cx="7" cy="7" r="4.5" stroke="currentColor" stroke-width="1.4"/><path d="M10.5 10.5L14 14" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск…" autocomplete="off" aria-label="Поиск по чатам" />
</div>
<div class="sa-chats-list" id="sa_chats_list" role="list"></div>
</aside>
</div>
</div>
</div>
<div class="sa-view" id="sa_view_cards" hidden>
<div class="sa-cards-layout">
<div class="sa-cards-list-pane">
<div class="sa-cards-filter">
<select id="sa_cards_kind" class="sa-select">
<option value="all">Все</option>
<option value="checkpoint">Checkpoints</option>
<option value="lora">LoRAs</option>
</select>
<button type="button" class="basic-button" id="sa_btn_cards_refresh">Обновить</button>
</div>
<div class="sa-cards-list" id="sa_cards_list"></div>
</div>
<div class="sa-cards-editor-pane">
<div class="sa-cards-editor-head">
<strong id="sa_card_title">Выбери модель</strong>
<span class="sa-card-badge" id="sa_card_badge" hidden></span>
</div>
<div class="sa-card-previews" id="sa_card_previews" hidden></div>
<div class="sa-card-form" id="sa_card_form">
<label>Triggers <input type="text" id="sa_card_triggers" placeholder="слово1, слово2" /></label>
<label>Weight <input type="number" id="sa_card_weight" step="0.05" min="0" max="2" value="0.8" /></label>
<label>When <textarea id="sa_card_when" rows="2" placeholder="Когда использовать"></textarea></label>
<label>Avoid <textarea id="sa_card_avoid" rows="2" placeholder="Чего избегать"></textarea></label>
<label>Prompt hint <textarea id="sa_card_hint" rows="2" placeholder="Подсказка для промпта"></textarea></label>
<label>Notes <textarea id="sa_card_notes" rows="2" placeholder="Заметки"></textarea></label>
<label>Civitai URL <input type="text" id="sa_card_url" placeholder="https://civitai…" /></label>
<label class="sa-card-json-toggle"><input type="checkbox" id="sa_card_show_json" /> Показать JSON</label>
<textarea id="sa_card_json" rows="8" hidden placeholder="Card JSON…" spellcheck="false"></textarea>
</div>
<div class="sa-cards-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_card_to_chat" title="В чат: используй эту модель/LoRA">В чат</button>
<button type="button" class="basic-button" id="sa_btn_card_meta">Загрузить Civitai meta</button>
<button type="button" class="basic-button" id="sa_btn_card_generate">Сгенерировать с Assistent</button>
<button type="button" class="basic-button" id="sa_btn_card_save">Сохранить карточку</button>
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="В очередь на следующий up">В wanted</button>
<span class="sa-status" id="sa_card_status"></span>
</div>
</div>
</div>
</div>
<div class="sa-view" id="sa_view_train" hidden>
<div class="sa-training" id="sa_training">
<div class="sa-training-tabs" role="tablist" aria-label="Разделы обучения">
@@ -315,7 +297,7 @@
<p class="sa-settings-hint">Автодействия после ответа модели и скилы текущей личности.</p>
<label class="sa-check" title="По умолчанию выкл. Кадр смотрит по кнопке «Посмотри результат», /look, или когда модель сама шлёт look_at. Эта галка — после каждого Generate."><input type="checkbox" id="sa_auto_vision" /> После Generate — look_at кадра</label>
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
<label class="sa-check" title="Если ход — кадр, Generate без кнопок Apply. Чат и «нравится» не запускают картинку."><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
<label class="sa-check" title="Устарело в 0.14: Generate запускает модель через generate:true или кнопки из сессии чата." hidden><input type="checkbox" id="sa_auto_generate" /> Авто-Generate после патча</label>
<label class="sa-check" title="По умолчанию выкл. Критика кадра — кнопка «Посмотри результат» или /look. Галка шлёт JPEG после каждого Generate."><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label>
<label class="sa-check" title="Выгружает чат-модель перед Generate (keep_alive:0). Для VL 7B обратная загрузка часто 1–2 мин — включай только если Generate падает по VRAM."><input type="checkbox" id="sa_park_llm" /> Park LLM перед Generate (VRAM)</label>
<label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label>
@@ -414,12 +396,11 @@
<option value="all">Все типы</option>
</select>
<input type="search" id="sa_mem_search" class="sa-mem-search" placeholder="Поиск…" autocomplete="off" />
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память и очередь wanted"></button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память"></button>
</div>
<div class="sa-mem-list" id="sa_mem_list"></div>
<div class="sa-mem-foot">
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span>
<span class="sa-mem-wanted" id="sa_mem_wanted" title="Модели в очереди на следующий gpu-rent up">Очередь wanted: —</span>
</div>
<label>memory_top_k <input type="number" id="sa_memory_top_k" min="1" max="30" step="1" value="10" /></label>
<div class="sa-settings-row">
@@ -433,6 +414,9 @@
<p class="sa-settings-hint">Контекст Ollama и Exact Turbo/RAW (пишется в overlay).</p>
<label>num_ctx <input type="number" id="sa_num_ctx" min="2048" max="131072" step="1024" value="16384" /></label>
<label>history_keep_turns <input type="number" id="sa_history_keep" min="1" max="32" step="1" value="4" /></label>
<label>compress_at <input type="number" id="sa_compress_at" min="0.4" max="0.95" step="0.05" value="0.7" title="Доля от (num_ctx num_predict)" /></label>
<label>chars_per_token <input type="number" id="sa_chars_per_token" min="1.5" max="8" step="0.1" value="3.2" /></label>
<label class="sa-check"><input type="checkbox" id="sa_compress_auto" checked /> Автосжатие перед отправкой</label>
<div class="sa-skills-label">Exact · Turbo</div>
<div class="sa-settings-row sa-knob-row">
<label>steps <input type="number" id="sa_exact_turbo_steps" min="1" max="64" step="1" /></label>
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"watch": "node scripts/build.mjs --watch",
"test": "node --test test/intent.test.js test/patch.test.js"
"test": "node --test test/intent.test.js test/patch.test.js test/context.test.js"
},
"devDependencies": {
"esbuild": "^0.25.0"
+263
View File
@@ -0,0 +1,263 @@
/**
* Turn activity timeline — shows model commands and pipeline steps in chat.
* Cursor-like, but compact and chat-native.
*/
const STEP_ICONS = {
think: '◇',
stream: '✎',
delta: '⇢',
ask: '?',
look: '◎',
prep: '↻',
generate: '▷',
merge: '⊕',
warm: '▲',
park: '▼',
inventory: '▤',
compress: '▤',
done: '✓',
skip: '',
error: '!',
};
export function createActivityController(opts = {}) {
const {
getMessagesEl,
scrollToBottom,
hideEmpty,
} = opts;
let card = null;
let listEl = null;
let titleEl = null;
let steps = [];
let open = true;
function ensureCard() {
const box = typeof getMessagesEl === 'function' ? getMessagesEl() : null;
if (!box) {
return null;
}
if (card && card.isConnected) {
return card;
}
if (typeof hideEmpty === 'function') {
hideEmpty();
}
card = document.createElement('div');
card.className = 'sa-activity sa-activity-live';
card.setAttribute('role', 'status');
card.setAttribute('aria-live', 'polite');
const head = document.createElement('button');
head.type = 'button';
head.className = 'sa-activity-head';
head.setAttribute('aria-expanded', 'true');
const spin = document.createElement('span');
spin.className = 'sa-activity-spin';
spin.setAttribute('aria-hidden', 'true');
titleEl = document.createElement('span');
titleEl.className = 'sa-activity-title';
titleEl.textContent = 'Assistent';
const chev = document.createElement('span');
chev.className = 'sa-activity-chev';
chev.setAttribute('aria-hidden', 'true');
chev.textContent = '▾';
head.appendChild(spin);
head.appendChild(titleEl);
head.appendChild(chev);
head.addEventListener('click', () => {
open = !open;
card.classList.toggle('sa-activity-collapsed', !open);
head.setAttribute('aria-expanded', open ? 'true' : 'false');
});
listEl = document.createElement('div');
listEl.className = 'sa-activity-steps';
card.appendChild(head);
card.appendChild(listEl);
box.appendChild(card);
if (typeof scrollToBottom === 'function') {
scrollToBottom();
}
return card;
}
function renderStep(step) {
const row = document.createElement('div');
row.className = `sa-activity-step sa-activity-${step.status || 'running'}`;
row.dataset.id = step.id;
const icon = document.createElement('span');
icon.className = 'sa-activity-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think;
const body = document.createElement('div');
body.className = 'sa-activity-body';
const label = document.createElement('div');
label.className = 'sa-activity-label';
label.textContent = step.label || step.id;
body.appendChild(label);
if (step.detail) {
const detail = document.createElement('div');
detail.className = 'sa-activity-detail';
detail.textContent = step.detail;
body.appendChild(detail);
}
row.appendChild(icon);
row.appendChild(body);
return row;
}
function paint() {
if (!ensureCard() || !listEl) {
return;
}
listEl.replaceChildren(...steps.map(renderStep));
const running = steps.find((s) => s.status === 'running');
const last = steps[steps.length - 1];
if (titleEl) {
titleEl.textContent = running
? running.label
: (last?.label || 'Assistent');
}
card.classList.toggle('sa-activity-live', steps.some((s) => s.status === 'running'));
card.classList.toggle('sa-activity-done', steps.length > 0 && steps.every((s) => s.status === 'done' || s.status === 'skip'));
if (typeof scrollToBottom === 'function') {
scrollToBottom();
}
}
function begin(title) {
steps = [];
card = null;
listEl = null;
titleEl = null;
open = true;
ensureCard();
if (titleEl && title) {
titleEl.textContent = title;
}
paint();
}
function upsert(id, patch) {
ensureCard();
let step = steps.find((s) => s.id === id);
if (!step) {
step = { id, kind: 'think', label: id, status: 'running', detail: '' };
steps.push(step);
}
Object.assign(step, patch);
if (!step.status) {
step.status = 'running';
}
paint();
return step;
}
function done(id, patch = {}) {
return upsert(id, { ...patch, status: 'done' });
}
function skip(id, patch = {}) {
return upsert(id, { ...patch, status: 'skip' });
}
function fail(id, patch = {}) {
return upsert(id, { ...patch, status: 'error' });
}
function finish(summary) {
steps.forEach((s) => {
if (s.status === 'running') {
s.status = 'done';
}
});
if (summary && titleEl) {
titleEl.textContent = summary;
}
paint();
if (card) {
card.classList.remove('sa-activity-live');
card.classList.add('sa-activity-done');
}
}
/** Describe a sparse model patch as human-readable activity steps. */
function noteModelCommands(patch) {
if (!patch || typeof patch !== 'object') {
return;
}
const keys = Object.keys(patch).filter((k) => patch[k] != null
&& !['actions', 'generate', 'ask', 'look_at', 'vision_from', 'vision_slots', 'notes', 'variants'].includes(k));
if (keys.length) {
done('delta', {
kind: 'delta',
label: 'Обновил сессию',
detail: keys.slice(0, 10).join(', '),
});
}
const ask = Array.isArray(patch.ask) ? patch.ask.map(String) : (patch.ask ? [String(patch.ask)] : []);
if (ask.length) {
upsert('ask', {
kind: 'ask',
label: `Запросил ${ask.join(', ')}`,
detail: 'подгружаю детали…',
status: 'running',
});
}
if (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null) {
const slots = [].concat(patch.look_at || patch.vision_from || patch.vision_slots || []);
upsert('look', {
kind: 'look',
label: 'Смотрит на кадр',
detail: slots.map(String).slice(0, 4).join(', '),
status: 'running',
});
}
if (patch.generate === true
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'))) {
upsert('generate', {
kind: 'generate',
label: 'Generate',
detail: 'ждёт пайплайн…',
status: 'running',
});
}
if (Array.isArray(patch.variants) && patch.variants.length) {
upsert('variants', {
kind: 'generate',
label: `Варианты ×${patch.variants.length}`,
status: 'running',
});
}
}
return {
begin,
upsert,
done,
skip,
fail,
finish,
noteModelCommands,
get steps() {
return steps.slice();
},
};
}
export function attachActivity(SA) {
SA.createActivityController = createActivityController;
}
+1039 -1528
View File
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
/**
* Context budget + conversation memory helpers for chat compression.
*/
export function emptyContextMemory() {
return {
summary: '',
untilCount: 0,
foldedTurns: 0,
at: 0,
uiCollapsed: false,
promptEvalCount: null,
};
}
export function normalizeContextMemory(raw) {
if (!raw || typeof raw !== 'object') {
return emptyContextMemory();
}
const summary = String(raw.summary || '').trim();
return {
summary,
untilCount: Math.max(0, Number(raw.untilCount) || 0),
foldedTurns: Math.max(0, Number(raw.foldedTurns) || 0),
at: Number(raw.at) || 0,
uiCollapsed: !!raw.uiCollapsed && !!summary,
promptEvalCount: raw.promptEvalCount != null ? Number(raw.promptEvalCount) || null : null,
};
}
/** Estimate tokens from character counts. */
export function charsToTokens(chars, charsPerToken = 3.2) {
const cpt = Math.max(1.5, Number(charsPerToken) || 3.2);
const n = Math.max(0, Number(chars) || 0);
return Math.ceil(n / cpt);
}
/**
* @param {{
* systemChars?: number,
* historyChars?: number,
* memoryChars?: number,
* numCtx?: number,
* numPredict?: number,
* charsPerToken?: number,
* compressAt?: number,
* promptEvalCount?: number|null,
* }} opts
*/
export function estimateBudget(opts = {}) {
const numCtx = Math.max(1024, Number(opts.numCtx) || 16384);
const numPredict = Math.max(256, Number(opts.numPredict) || 3072);
const charsPerToken = Math.max(1.5, Number(opts.charsPerToken) || 3.2);
const compressAt = Math.min(0.95, Math.max(0.4, Number(opts.compressAt) || 0.7));
const systemChars = Math.max(0, Number(opts.systemChars) || 0);
const historyChars = Math.max(0, Number(opts.historyChars) || 0);
const memoryChars = Math.max(0, Number(opts.memoryChars) || 0);
const inputChars = systemChars + historyChars + memoryChars;
const estimated = charsToTokens(inputChars, charsPerToken);
const used = opts.promptEvalCount != null && Number(opts.promptEvalCount) > 0
? Number(opts.promptEvalCount)
: estimated;
const headroom = Math.max(1024, numCtx - numPredict);
const threshold = Math.floor(headroom * compressAt);
const ratio = numCtx > 0 ? used / numCtx : 0;
let level = 'ok';
if (ratio >= 0.85 || used >= threshold) {
level = 'hot';
} else if (ratio >= 0.65 || used >= threshold * 0.85) {
level = 'warn';
}
return {
numCtx,
numPredict,
headroom,
threshold,
systemChars,
historyChars,
memoryChars,
inputChars,
estimated,
used,
fromEval: opts.promptEvalCount != null && Number(opts.promptEvalCount) > 0,
ratio,
level,
charsPerToken,
compressAt,
};
}
/**
* Whether auto-compress should run before the next chat turn.
* @param {ReturnType<typeof estimateBudget>} budget
* @param {{ untilCount?: number, summary?: string }} memory
* @param {number} historyLen — full transcript length
* @param {{ keepMessages?: number }} opts — messages kept raw (turns*2)
*/
export function shouldCompress(budget, memory, historyLen, opts = {}) {
const keep = Math.max(2, Number(opts.keepMessages) || 8);
const len = Math.max(0, Number(historyLen) || 0);
const until = Math.max(0, Number(memory?.untilCount) || 0);
const uncovered = Math.max(0, len - until);
if (uncovered <= keep) {
return false;
}
const used = budget?.used ?? 0;
const threshold = budget?.threshold ?? Infinity;
return used >= threshold;
}
/**
* Messages the model should see: optional covered-by-summary skip + last keep raw.
* @param {Array<{role:string,content?:string,systemish?:boolean}>} history
* @param {{ untilCount?: number }} memory
* @param {number} keepTurns
*/
export function assembleModelMessages(history, memory, keepTurns) {
const keep = Math.max(1, Number(keepTurns) || 4) * 2;
const until = Math.max(0, Number(memory?.untilCount) || 0);
const list = (history || []).filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish);
const afterSummary = until > 0 ? list.slice(until) : list;
const window = afterSummary.length > keep ? afterSummary.slice(-keep) : afterSummary;
return window.map((m) => ({
role: m.role,
content: String(m.content || '').slice(0, 4000),
}));
}
/** How many leading messages can be folded into a new summary (leave keep raw). */
export function messagesToFold(history, memory, keepTurns) {
const keep = Math.max(1, Number(keepTurns) || 4) * 2;
const list = (history || []).filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish);
const until = Math.max(0, Number(memory?.untilCount) || 0);
const foldEnd = Math.max(until, list.length - keep);
if (foldEnd <= until) {
return [];
}
return list.slice(until, foldEnd);
}
export function mergeSummary(oldSummary, incoming) {
const next = String(incoming || '').trim();
if (!next) {
return String(oldSummary || '').trim();
}
const prev = String(oldSummary || '').trim();
if (!prev) {
return next;
}
// Prefer the model output when it already incorporates prior memory.
return next;
}
export function formatTokenShort(n) {
const v = Math.max(0, Number(n) || 0);
if (v >= 10000) {
return `${(v / 1000).toFixed(1)}k`;
}
if (v >= 1000) {
return `${(v / 1000).toFixed(1)}k`;
}
return String(Math.round(v));
}
export function conversationMemoryBlock(memory, maxChars = 2400) {
const m = normalizeContextMemory(memory);
if (!m.summary) {
return null;
}
let text = m.summary;
if (text.length > maxChars) {
text = `${text.slice(0, maxChars)}`;
}
return {
summary: text,
until_count: m.untilCount,
folded_turns: m.foldedTurns,
};
}
export function attachContext(SA) {
SA.context = {
emptyContextMemory,
normalizeContextMemory,
charsToTokens,
estimateBudget,
shouldCompress,
assembleModelMessages,
messagesToFold,
mergeSummary,
formatTokenShort,
conversationMemoryBlock,
};
}
+21 -140
View File
@@ -1,4 +1,4 @@
/** Turn intent heuristics — pure functions testable with node --test. */
/** Turn intent — veto only; generate comes from model `generate: true` (or legacy actions). */
export function cyrTokenRe(alts) {
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
@@ -6,55 +6,9 @@ export function cyrTokenRe(alts) {
return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
}
export function userAsksGenerate(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
return true;
}
if (/^(ещё|еще)(\s+раз)?([!.…\s]|$)/i.test(t)) {
return true;
}
const letter = '[0-9A-Za-z_А-Яа-яЁё]';
const stem = `${letter}*`;
return cyrTokenRe(
'сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|'
+ `сделай\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ `хочу\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ 'run\\s+generat|/gen',
).test(t);
}
export function userAsksContinue(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(давай\s+дальше|продолжай|продолжим|go\s+on|continue|keep\s+going|next(\s+one)?|next\s+frame)([!.…\s]|$)/i.test(t)) {
return true;
}
return cyrTokenRe(
'давай\\s+дальше|следующ(ий|ая|ее|ую)\\s+кадр|ещё\\s+кадр|еще\\s+кадр|'
+ 'кадр\\s*№?\\s*\\d+|сделай\\s+следующ',
).test(t);
}
export function isSameButAspectRequest(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
return cyrTokenRe(
'тот\\s+же\\s+(кадр|сцена|промпт|prompt)|так\\s+же\\s+но\\s+(друг|иной)\\s+(формат|размер|aspect|соотношен)|'
+ 'same\\s+but\\s+(wider|taller|16:9|4:3|portrait|landscape)',
).test(t);
}
export function userAsksNoGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksGenerate(t)) {
if (!t) {
return false;
}
if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
@@ -63,19 +17,12 @@ export function userAsksNoGenerate(text) {
return cyrTokenRe(
'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
+ 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
+ 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
+ 'не\\s+рисуй|не\\s+запускай\\s+генер',
+ 'не\\s+генерир[а-яё]*|без\\s+генерац[а-яё]*|не\\s+надо\\s+генер[а-яё]*|только\\s+запомн[а-яё]*|пока\\s+запомн[а-яё]*|'
+ 'не\\s+рисуй|не\\s+запускай\\s+генер[а-яё]*|'
+ 'только\\s+(ответь|скажи|объясни)',
).test(t);
}
export function userCommandsGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t)) {
return false;
}
return userAsksGenerate(t) || userAsksContinue(t) || isSameButAspectRequest(t);
}
export function userAsksLook(text) {
const t = String(text || '').trim();
if (!t) {
@@ -93,68 +40,15 @@ export function userAsksLook(text) {
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
}
export function userIsChatNotFrame(text) {
export function isSameButAspectRequest(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(ок|окей|ok|okay|ладно|хорошо|понял|ясно|спасибо|thanks)([!.…\s]*)$/i.test(t)) {
return true;
}
if (cyrTokenRe(
'что\\s+такое|как\\s+работает|зачем\\s+|какие\\s+(лор|модел|чекпоинт)|'
+ 'список\\s+лор|где\\s+настрой|что\\s+значит|'
+ 'нравит|спасибо|благодар|почему\\s+так|что\\s+ты\\s+(сделал|изменил)|'
+ 'только\\s+(ответь|скажи|объясни)|без\\s+(кадр|генерац)|не\\s+надо\\s+кадр',
).test(t) && !userAsksGenerate(t) && !userAsksContinue(t)) {
return true;
}
return false;
}
export function userImpliesGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t) || userIsChatNotFrame(t)) {
return false;
}
if (userCommandsGenerate(t)) {
return true;
}
if (t.length < 8) {
return false;
}
const wantsLook = userAsksLook(t);
const wantsRedraw = cyrTokenRe('поправь|исправь|перегенерир|перерисуй|улучши|переделай').test(t)
|| /\b(fix|redo|redraw|improve)\b/i.test(t);
if (wantsLook && !wantsRedraw) {
return false;
}
if (cyrTokenRe(
'нарису|сгенер|перерису|'
+ 'сделай\\s+(картинк|изображен|фото|кадр)|'
+ 'хочу\\s+(картинк|изображен|фото|увидеть|видеть)|'
+ 'покажи\\s+как\\s+(она|он|это)|'
+ 'сделай\\s+(её|ее|его|мне)\\s|'
+ 'пусть\\s+будет|'
+ 'другой\\s+(ракурс|свет|наряд|поза)|'
+ 'поменяй\\s+(позу|свет|одежд|фон)|добавь\\s+(свет|детал)|'
+ 'ещё\\s+одн|еще\\s+одн',
).test(t)) {
return true;
}
if (/\b(draw|paint|render|make her|make him|another one|new frame)\b/i.test(t)) {
return true;
}
const isQuestion = /[?]\s*$/.test(t);
if (isQuestion) {
return cyrTokenRe('нарису|сгенер|можешь\\s+(сделать|нарисовать)|можно\\s+(картинк|сгенер)').test(t);
}
return false;
}
export function packBlocksAutoGenerate(pack) {
const p = String(pack || '');
return p === 'describe_ref' || p === 'catalog_card' || p === 'author_persona' || p === 'debug_explain';
return cyrTokenRe(
'тот\\s+же\\s+(кадр|сцена|промпт|prompt)|так\\s+же\\s+но\\s+(друг|иной)\\s+(формат|размер|aspect|соотношен)|'
+ 'same\\s+but\\s+(wider|taller|16:9|4:3|portrait|landscape)',
).test(t);
}
export function packWantsVision(pack) {
@@ -162,30 +56,17 @@ export function packWantsVision(pack) {
return p === 'critique_image' || p === 'describe_ref' || p === 'compose_scene' || p === 'inpaint_edit';
}
export function resolveTurnIntent(patch, userText, opts = {}, packId = '') {
const machine = !!opts.machineTurn;
const vetoed = !machine && userAsksNoGenerate(userText);
const commanded = !!opts.userWantsGenerate || (!machine && userCommandsGenerate(userText));
const implied = !machine && userImpliesGenerate(userText);
const modelAsked = Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate');
let generate;
if (vetoed || opts.fromAutoCritique) {
generate = false;
} else if (commanded) {
generate = true;
} else if (packBlocksAutoGenerate(packId)) {
generate = false;
} else {
generate = modelAsked || implied;
}
/** Model generate + explicit veto. No RU imply/command heuristics. */
export function resolveTurnIntent(patch, userText, opts = {}) {
const vetoed = !opts.machineTurn && userAsksNoGenerate(userText);
const modelAsked = patch?.generate === true
|| (Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate'));
const generate = !vetoed && !opts.fromAutoCritique && !!modelAsked;
const hasLook = !!patch
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const honorLook = opts.fromAutoCritique || opts.fromVisionHop
|| (!machine && userAsksLook(userText))
|| packWantsVision(packId);
const look = !!(hasLook && !vetoed && !generate && honorLook);
return { generate, look, vetoed };
const look = !!(hasLook && !vetoed && !generate);
const ask = Array.isArray(patch?.ask)
? patch.ask.map(String)
: (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []);
return { generate, look, vetoed, ask };
}
+6
View File
@@ -1,11 +1,17 @@
import { attachApi } from './api.js';
import { attachPatch, setPatchKeys } from './patch.js';
import { attachPersist } from './persist.js';
import { attachSession } from './session.js';
import { attachContext } from './context.js';
import { attachActivity } from './activity.js';
window.SA = window.SA || {};
attachApi(window.SA);
attachPatch(window.SA);
attachPersist(window.SA);
attachSession(window.SA);
attachContext(window.SA);
attachActivity(window.SA);
/** Called from app after AssistentGetConfig — single source: Config/_base/patch-keys.json */
window.SA.applyConfigPatchKeys = function (config) {
+19 -39
View File
@@ -1,17 +1,15 @@
/** Patch detection / extraction — mirrors AssistentPatch.cs. */
/** Patch detection / extraction — mirrors AssistentPatch.cs (0.14 sparse session deltas). */
const DEFAULT_PATCH_KEYS = [
'prompt', 'negative', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', 'scheduler',
'actions', 'search_query', 'civitai_query',
'actions', 'generate', 'ask',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'memories', 'memory',
'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
'inventory_query', 'skills', 'persona_shelves', 'persona_clone', 'persona', 'controls',
'variants',
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'persona', 'controls',
'inventory_query', 'variants',
];
let PATCH_KEYS = DEFAULT_PATCH_KEYS.slice();
@@ -32,27 +30,10 @@ function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null;
}
export function isCardObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
const genish = !!(obj.prompt != null || obj.negative != null || obj.loras || obj.actions
|| obj.width || obj.height || obj.steps != null || obj.cfg != null || obj.aspect || obj.seed != null
|| obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
return true;
}
return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
}
export function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
if (isCardObject(obj)) {
return false;
}
return PATCH_KEYS.some((k) => has(obj, k));
}
@@ -60,8 +41,12 @@ export function normalizePatch(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
patch.search_query = patch.civitai_query;
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
if (patch.generate === true || acts.includes('generate')) {
patch.generate = true;
}
if (typeof patch.ask === 'string') {
patch.ask = [patch.ask];
}
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
patch.init_creativity = patch.denoise;
@@ -100,7 +85,13 @@ export function isTerminalStreamPatch(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
if (isCardObject(obj)) {
if (obj.generate === true) {
return true;
}
if (Array.isArray(obj.ask) && obj.ask.length) {
return true;
}
if (typeof obj.ask === 'string' && obj.ask) {
return true;
}
if (Array.isArray(obj.variants) && obj.variants.length) {
@@ -109,17 +100,8 @@ export function isTerminalStreamPatch(obj) {
if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
return true;
}
if (obj.search_query != null || obj.civitai_query != null
|| obj.memory_query != null || obj.tag_query != null || obj.inventory_query != null) {
return true;
}
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
const hopOrGen = [
'skill_load', 'persona_read', 'memory_get', 'memory_search', 'lookup_tags',
'list_inventory', 'search_civitai', 'interrupt', 'generate',
'memory_upsert', 'user_pref_upsert',
];
if (acts.some((a) => hopOrGen.includes(a))) {
if (acts.includes('generate')) {
return true;
}
if (String(obj.prompt || '').trim().length >= 48) {
@@ -127,8 +109,7 @@ export function isTerminalStreamPatch(obj) {
}
if (obj.loras != null || obj.aspect != null || obj.steps != null
|| obj.width != null || obj.height != null || obj.cfg != null
|| obj.seed != null || obj.controls != null
|| obj.memories != null || obj.user_prefs != null) {
|| obj.seed != null || obj.controls != null) {
return true;
}
return false;
@@ -137,7 +118,6 @@ export function isTerminalStreamPatch(obj) {
export function attachPatch(SA) {
SA.PATCH_KEYS = PATCH_KEYS;
SA.setPatchKeys = setPatchKeys;
SA.isCardObject = isCardObject;
SA.isPatchObject = isPatchObject;
SA.isTerminalStreamPatch = isTerminalStreamPatch;
SA.normalizePatch = normalizePatch;
+368
View File
@@ -0,0 +1,368 @@
/**
* Per-chat generation session — source of truth for Generate params, board, LoRAs.
* Sparse model deltas merge into the active session; buttons always generate from it.
*/
const MAX_DATA_URL_CHARS = 350_000;
const GEN_KEYS = [
'prompt', 'negative', 'width', 'height', 'aspect', 'steps', 'cfg', 'sigma_shift',
'seed', 'sampler', 'scheduler', 'batch', 'checkpoint', 'loras', 'controls',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
];
export function emptySession() {
return {
gen: {
prompt: '',
negative: '',
width: null,
height: null,
aspect: null,
steps: null,
cfg: null,
sigma_shift: null,
seed: null,
sampler: null,
scheduler: null,
batch: null,
checkpoint: null,
loras: [],
controls: {},
use_init_image: false,
clear_init_image: false,
init_creativity: null,
denoise: null,
use_mask_image: false,
clear_mask_image: false,
mask_blur: null,
mask_grow: null,
},
board: {
slots: [],
selectedSlotId: 'ref1',
genResults: [],
selectedGenResultId: null,
refSeq: 1,
},
persona: 'neutral',
pack: 'ordinary',
context_memory: null,
};
}
/** Normalize boolean generate + legacy actions:["generate"]. */
export function normalizeDelta(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const delta = { ...raw };
const acts = Array.isArray(delta.actions) ? delta.actions.map(String) : [];
if (delta.generate === true || acts.includes('generate')) {
delta.generate = true;
}
if (typeof delta.ask === 'string') {
delta.ask = [delta.ask];
}
if (!Array.isArray(delta.ask)) {
delete delta.ask;
} else {
delta.ask = delta.ask.map(String).filter(Boolean);
}
return delta;
}
export function patchWantsGenerate(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
if (patch.generate === true) {
return true;
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
return acts.includes('generate');
}
export function patchAskList(patch) {
const n = normalizeDelta(patch);
return Array.isArray(n?.ask) ? n.ask : [];
}
/**
* Merge sparse model delta into session.gen (and pack/persona if present).
* Does not run Generate — caller decides via patchWantsGenerate + veto.
*/
export function mergeDelta(session, rawDelta) {
const base = session && typeof session === 'object' ? structuredCloneSession(session) : emptySession();
const delta = normalizeDelta(rawDelta);
if (!delta) {
return base;
}
if (!base.gen) {
base.gen = emptySession().gen;
}
for (const key of GEN_KEYS) {
if (delta[key] === undefined || delta[key] === null) {
continue;
}
if (key === 'loras' && Array.isArray(delta.loras)) {
base.gen.loras = delta.loras.map((l) => ({
name: l?.name || l,
weight: l?.weight != null ? Number(l.weight) : 1,
triggers: Array.isArray(l?.triggers) ? l.triggers : undefined,
trigger_phrase: l?.trigger_phrase || undefined,
})).filter((l) => l.name);
continue;
}
if (key === 'controls' && typeof delta.controls === 'object') {
base.gen.controls = { ...(base.gen.controls || {}), ...delta.controls };
continue;
}
if (key === 'checkpoint') {
base.gen.checkpoint = typeof delta.checkpoint === 'object'
? { ...delta.checkpoint }
: { name: String(delta.checkpoint) };
continue;
}
base.gen[key] = delta[key];
}
if (delta.images != null && delta.batch == null) {
base.gen.batch = delta.images;
}
if (delta.pack) {
base.pack = String(delta.pack);
}
if (delta.persona) {
base.persona = String(delta.persona);
}
return base;
}
function structuredCloneSession(session) {
try {
return JSON.parse(JSON.stringify(session));
} catch {
return emptySession();
}
}
function slimSrc(src) {
if (!src || typeof src !== 'string') {
return null;
}
const s = src.trim();
if (!s || s.startsWith('#')) {
return null;
}
if (s.startsWith('data:') && s.length > MAX_DATA_URL_CHARS) {
return null;
}
return s;
}
/** Snapshot live UI + board into a persistable session object. */
export function snapshotFromLive({
genFields,
board,
persona,
pack,
context_memory,
}) {
const session = emptySession();
if (genFields && typeof genFields === 'object') {
for (const key of GEN_KEYS) {
if (genFields[key] !== undefined) {
session.gen[key] = genFields[key];
}
}
}
session.persona = persona || 'neutral';
session.pack = pack || 'ordinary';
if (context_memory && typeof context_memory === 'object') {
session.context_memory = context_memory;
}
if (board && typeof board === 'object') {
session.board = {
slots: (board.slots || []).map((s) => ({
id: s.id,
type: s.type,
label: s.label,
src: slimSrc(s.src),
attach: !!s.attach,
note: s.note || null,
})),
selectedSlotId: board.selectedSlotId || 'ref1',
genResults: (board.genResults || []).map((r) => ({
id: r.id,
label: r.label,
src: slimSrc(r.src),
patch: r.patch || null,
})),
selectedGenResultId: board.selectedGenResultId || null,
refSeq: board.refSeq || 1,
};
}
return session;
}
/** Convert legacy flat chat.params into session shape. */
export function sessionFromLegacyParams(params) {
if (!params || typeof params !== 'object') {
return emptySession();
}
if (params.gen && typeof params.gen === 'object') {
const s = emptySession();
s.gen = { ...s.gen, ...params.gen };
if (params.board && typeof params.board === 'object') {
s.board = { ...s.board, ...params.board };
}
s.persona = params.persona || s.persona;
s.pack = params.pack || s.pack;
if (params.context_memory && typeof params.context_memory === 'object') {
s.context_memory = params.context_memory;
}
return s;
}
const s = emptySession();
for (const key of GEN_KEYS) {
if (params[key] !== undefined && params[key] !== null) {
s.gen[key] = params[key];
}
}
if (Array.isArray(params.loras)) {
s.gen.loras = params.loras;
}
if (params.checkpoint) {
s.gen.checkpoint = typeof params.checkpoint === 'object'
? params.checkpoint
: { name: String(params.checkpoint) };
}
s.persona = params.persona || 'neutral';
s.pack = params.pack || 'ordinary';
if (params.context_memory && typeof params.context_memory === 'object') {
s.context_memory = params.context_memory;
}
s.board.genResults = Array.isArray(params.genResults) ? params.genResults : [];
s.board.selectedGenResultId = params.selectedGenResultId || null;
if (Array.isArray(params.slots)) {
s.board.slots = params.slots;
}
if (params.selectedSlotId) {
s.board.selectedSlotId = params.selectedSlotId;
}
if (params.refSeq) {
s.board.refSeq = params.refSeq;
}
return s;
}
/** Persist blob for AssistentSaveChat.params */
export function toPersistParams(session) {
const s = session && typeof session === 'object' ? session : emptySession();
const out = {
gen: s.gen || emptySession().gen,
board: s.board || emptySession().board,
persona: s.persona || 'neutral',
pack: s.pack || 'ordinary',
};
if (s.context_memory && typeof s.context_memory === 'object') {
out.context_memory = s.context_memory;
}
return out;
}
function slimText(t, max) {
const s = String(t || '');
if (s.length <= max) {
return s;
}
return `${s.slice(0, max)}`;
}
/** Compact context for every LLM turn. */
export function compactContext(session, extras = {}) {
const s = session && typeof session === 'object' ? session : emptySession();
const g = s.gen || {};
const board = s.board || {};
const slots = board.slots || [];
const genSlot = slots.find((x) => x.type === 'generate' || x.id === 'generate');
const refs = slots.filter((x) => x.type === 'ref' || String(x.id || '').startsWith('ref'));
return {
session: true,
prompt: slimText(g.prompt, extras.promptMax || 2000),
negative: slimText(g.negative, 500),
aspect: g.aspect || null,
width: g.width ?? null,
height: g.height ?? null,
steps: g.steps ?? null,
cfg: g.cfg ?? null,
seed: g.seed ?? null,
sigma_shift: g.sigma_shift ?? null,
sampler: g.sampler || null,
scheduler: g.scheduler || null,
batch: g.batch ?? null,
checkpoint: g.checkpoint?.name || g.checkpoint || null,
selected_loras: (g.loras || []).map((l) => ({
name: l.name || l,
weight: l.weight != null ? l.weight : 1,
})),
persona: s.persona || 'neutral',
pack: s.pack || 'ordinary',
board: {
has_generate: !!(genSlot?.src || (board.genResults || []).some((r) => r.src)),
refs: refs.map((r) => ({ id: r.id, has_image: !!r.src, attach: !!r.attach })),
gen_results: (board.genResults || []).map((r) => ({
id: r.id,
label: r.label,
has_image: !!r.src,
selected: r.id === board.selectedGenResultId,
})),
selected_slot: board.selectedSlotId || null,
},
architecture_ok: extras.architecture_ok !== false,
...extras.extra,
};
}
/** Full dump for ask:settings hop. */
export function fullSettingsDump(session, extras = {}) {
const compact = compactContext(session, extras);
const s = session && typeof session === 'object' ? session : emptySession();
return {
...compact,
detail: 'settings',
gen: { ...(s.gen || {}) },
controls: s.gen?.controls || {},
exact: extras.exact || null,
krea_profiles: extras.kreaProfiles || null,
session_exact: extras.sessionExact || null,
};
}
export function resolveTurnIntent(patch, userText, { vetoFn } = {}) {
const delta = normalizeDelta(patch) || {};
const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false;
const generate = !vetoed && patchWantsGenerate(delta);
const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null;
const look = !!(hasLook && !generate && !vetoed);
const ask = patchAskList(delta);
return { generate, look, vetoed, ask };
}
export function attachSession(SA) {
SA.session = {
emptySession,
normalizeDelta,
mergeDelta,
patchWantsGenerate,
patchAskList,
snapshotFromLive,
sessionFromLegacyParams,
toPersistParams,
compactContext,
fullSettingsDump,
resolveTurnIntent,
GEN_KEYS,
};
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
emptyContextMemory,
normalizeContextMemory,
charsToTokens,
estimateBudget,
shouldCompress,
assembleModelMessages,
messagesToFold,
mergeSummary,
conversationMemoryBlock,
} from '../src/context.js';
describe('context.js', () => {
it('charsToTokens ceil divide', () => {
assert.equal(charsToTokens(32, 3.2), 10);
assert.equal(charsToTokens(0, 3.2), 0);
});
it('estimateBudget prefers prompt_eval_count', () => {
const est = estimateBudget({
systemChars: 3200,
historyChars: 3200,
memoryChars: 0,
numCtx: 16384,
numPredict: 3072,
charsPerToken: 3.2,
compressAt: 0.7,
});
assert.equal(est.fromEval, false);
assert.ok(est.estimated > 0);
assert.equal(est.used, est.estimated);
const fact = estimateBudget({
...est,
systemChars: 3200,
historyChars: 3200,
promptEvalCount: 14000,
numCtx: 16384,
numPredict: 3072,
compressAt: 0.7,
});
assert.equal(fact.fromEval, true);
assert.equal(fact.used, 14000);
assert.equal(fact.level, 'hot');
});
it('shouldCompress only when over threshold and uncovered > keep', () => {
const budget = estimateBudget({
systemChars: 20000,
historyChars: 20000,
numCtx: 16384,
numPredict: 3072,
compressAt: 0.7,
charsPerToken: 3.2,
});
assert.equal(shouldCompress(budget, emptyContextMemory(), 20, { keepMessages: 8 }), true);
assert.equal(shouldCompress(budget, emptyContextMemory(), 6, { keepMessages: 8 }), false);
assert.equal(
shouldCompress(budget, { untilCount: 12, summary: 'x' }, 20, { keepMessages: 8 }),
false,
);
});
it('assembleModelMessages skips covered prefix then keeps last window', () => {
const history = [];
for (let i = 0; i < 12; i++) {
history.push({ role: i % 2 === 0 ? 'user' : 'assistant', content: `m${i}` });
}
const msgs = assembleModelMessages(history, { untilCount: 4 }, 2);
assert.equal(msgs.length, 4);
assert.equal(msgs[0].content, 'm8');
assert.equal(msgs[3].content, 'm11');
});
it('messagesToFold leaves keepTurns raw', () => {
const history = Array.from({ length: 10 }, (_, i) => ({
role: i % 2 === 0 ? 'user' : 'assistant',
content: `m${i}`,
}));
const fold = messagesToFold(history, emptyContextMemory(), 2);
assert.equal(fold.length, 6);
assert.equal(fold[0].content, 'm0');
assert.equal(fold[5].content, 'm5');
});
it('normalize + merge + conversation block', () => {
const m = normalizeContextMemory({ summary: ' hello ', untilCount: 4, uiCollapsed: true });
assert.equal(m.summary, 'hello');
assert.equal(m.uiCollapsed, true);
assert.equal(mergeSummary('old', 'new'), 'new');
assert.equal(mergeSummary('old', ''), 'old');
const block = conversationMemoryBlock(m, 100);
assert.equal(block.summary, 'hello');
assert.equal(block.until_count, 4);
assert.equal(conversationMemoryBlock(emptyContextMemory()), null);
});
});
+17 -9
View File
@@ -2,7 +2,6 @@ import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
userAsksLook,
userCommandsGenerate,
userAsksNoGenerate,
resolveTurnIntent,
packWantsVision,
@@ -14,17 +13,26 @@ describe('intent.js', () => {
assert.equal(userAsksLook('нарисуй лису'), false);
});
it('userCommandsGenerate respects veto', () => {
assert.equal(userCommandsGenerate('сгенерируй кадр'), true);
assert.equal(userCommandsGenerate('только запомни промпт'), false);
it('userAsksNoGenerate vetoes remember / no-gen', () => {
assert.equal(userAsksNoGenerate('только запомни промпт'), true);
assert.equal(userAsksNoGenerate('не генерируй'), true);
assert.equal(userAsksNoGenerate('нарисуй лису'), false);
});
it('resolveTurnIntent honors look without generate', () => {
const patch = { look_at: ['generate'] };
const intent = resolveTurnIntent(patch, 'что не так с кадром?', {}, 'ordinary');
assert.equal(intent.generate, false);
assert.equal(intent.look, true);
it('resolveTurnIntent uses model generate only + veto', () => {
const lookOnly = resolveTurnIntent({ look_at: ['generate'] }, 'что не так с кадром?', {});
assert.equal(lookOnly.generate, false);
assert.equal(lookOnly.look, true);
const gen = resolveTurnIntent({ prompt: 'fox', generate: true }, 'нарисуй лису', {});
assert.equal(gen.generate, true);
const vetoed = resolveTurnIntent({ prompt: 'fox', generate: true }, 'только запомни промпт', {});
assert.equal(vetoed.generate, false);
assert.equal(vetoed.vetoed, true);
const noHeuristic = resolveTurnIntent({ prompt: 'fox' }, 'нарисуй красивую лису в снегу', {});
assert.equal(noHeuristic.generate, false);
});
it('packWantsVision for critique pack', () => {
+54 -14
View File
@@ -3,35 +3,75 @@ import assert from 'node:assert/strict';
import {
extractPatch,
isPatchObject,
isCardObject,
normalizePatch,
setPatchKeys,
} from '../src/patch.js';
import {
mergeDelta,
emptySession,
patchWantsGenerate,
sessionFromLegacyParams,
toPersistParams,
resolveTurnIntent,
} from '../src/session.js';
describe('patch.js', () => {
it('extractPatch finds generation patch in fence', () => {
const text = 'Here you go\n```json\n{"prompt":"A red fox in snow","actions":["generate"]}\n```';
const text = 'Here you go\n```json\n{"prompt":"A red fox in snow","generate":true}\n```';
const { prose, patch } = extractPatch(text);
assert.ok(patch);
assert.equal(patch.prompt, 'A red fox in snow');
assert.equal(patch.generate, true);
assert.ok(!prose.includes('```'));
});
it('isCardObject vs isPatchObject', () => {
const card = { kind: 'lora', name: 'Foo', triggers: ['bar'] };
const gen = { prompt: 'test', actions: ['generate'] };
assert.equal(isCardObject(card), true);
assert.equal(isPatchObject(card), false);
assert.equal(isPatchObject(gen), true);
});
it('normalizePatch aliases civitai_query', () => {
const p = normalizePatch({ civitai_query: 'anime style' });
assert.equal(p.search_query, 'anime style');
it('normalizePatch maps legacy actions generate', () => {
const p = normalizePatch({ prompt: 'x', actions: ['generate'] });
assert.equal(p.generate, true);
});
it('scheduler-only patch is detected with full key list', () => {
setPatchKeys(['prompt', 'scheduler']);
setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']);
assert.equal(isPatchObject({ scheduler: 'euler' }), true);
});
});
describe('session.js', () => {
it('mergeDelta is sparse and keeps other fields', () => {
let s = emptySession();
s.gen.prompt = 'old';
s.gen.steps = 8;
s = mergeDelta(s, { aspect: '16:9', generate: true });
assert.equal(s.gen.prompt, 'old');
assert.equal(s.gen.steps, 8);
assert.equal(s.gen.aspect, '16:9');
assert.equal(patchWantsGenerate({ generate: true }), true);
assert.equal(patchWantsGenerate({ actions: ['generate'] }), true);
});
it('legacy flat params upgrade to session shape', () => {
const s = sessionFromLegacyParams({
prompt: 'fox',
steps: 28,
loras: [{ name: 'a', weight: 0.8 }],
genResults: [{ id: 'var1', src: '/View/x.png' }],
persona: 'leonid',
});
assert.equal(s.gen.prompt, 'fox');
assert.equal(s.gen.steps, 28);
assert.equal(s.board.genResults.length, 1);
const blob = toPersistParams(s);
assert.ok(blob.gen);
assert.ok(blob.board);
});
it('resolveTurnIntent vetoes generate', () => {
const intent = resolveTurnIntent(
{ generate: true, prompt: 'x' },
'не генерируй',
{ vetoFn: (t) => /не\s+генерир/i.test(t) },
);
assert.equal(intent.generate, false);
assert.equal(intent.vetoed, true);
});
});