Ship persona shelves, Exact controls, and overlay author pipeline.

Add Leonid as a shelf-based example with preference_bias slider; support /persona new clone-to-overlay and UI-only delete.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 01:48:02 +03:00
co-authored by Cursor
parent ac8e30c7c8
commit cf89348f85
26 changed files with 1311 additions and 102 deletions
+50
View File
@@ -669,9 +669,59 @@
.sa-header-right { .sa-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 0.4rem; gap: 0.4rem;
} }
.sa-persona-wrap {
display: inline-flex;
align-items: center;
gap: 0.2rem;
}
.sa-persona-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem 0.85rem;
width: 100%;
order: 5;
padding: 0.15rem 0 0;
}
.sa-control-row {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
min-width: 11rem;
}
.sa-control-row label {
opacity: 0.75;
white-space: nowrap;
}
.sa-control-row input[type="range"] {
width: 7.5rem;
accent-color: color-mix(in srgb, #f0883e 70%, currentColor);
}
.sa-control-val {
font-variant-numeric: tabular-nums;
min-width: 2.4rem;
opacity: 0.85;
}
#sa_persona_delete:not([hidden]) {
opacity: 0.7;
}
#sa_persona_delete:hover {
opacity: 1;
color: #e06c75;
}
.sa-icon-btn { .sa-icon-btn {
min-width: 2rem; min-width: 2rem;
padding-left: 0.45rem; padding-left: 0.45rem;
+238 -1
View File
@@ -2150,6 +2150,8 @@
} }
} }
fillEmptyParamsFromExact(); fillEmptyParamsFromExact();
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
}); });
} }
@@ -3212,6 +3214,34 @@
setStatus('Prompt Images: drop the ref into the Swarm prompt box (no auto helper yet)'); setStatus('Prompt Images: drop the ref into the Swarm prompt box (no auto helper yet)');
} }
} }
// Persona Exact controls (model or user patch). Ignore persona_delete.
if (patch.controls && typeof patch.controls === 'object' && !Array.isArray(patch.controls)) {
const schema = state.config?.controls || {};
const next = { ...(state.config?.control_values || state.exact?.controls || {}) };
for (const [k, v] of Object.entries(patch.controls)) {
if (schema[k]) {
next[k] = v;
}
}
savePersonaControls(next);
renderPersonaControls(schema, next);
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
const wantSwitch = acts.includes('persona_switch')
|| (patch.persona && typeof patch.persona === 'string')
|| patch._persona_cloned
|| patch._persona_written;
if (wantSwitch) {
const newId = String(patch.persona || patch._persona_cloned || patch._persona_written || '').trim();
if (newId && AssistentConfigSafeIdClient(newId)) {
await refreshPersonasAndSwitch(newId);
} else if (acts.includes('persona_clone') || acts.includes('persona_write') || patch.persona_clone) {
await refreshPersonasAndSwitch(null);
}
}
syncChipHighlight(); syncChipHighlight();
syncLiveParamsBar(); syncLiveParamsBar();
syncBuildGenButton(); syncBuildGenButton();
@@ -3224,10 +3254,46 @@
} }
} }
if (!state.restoringChat) { if (!state.restoringChat) {
setStatus('Applied patch'); setStatus(patch._persona_error ? `Persona: ${patch._persona_error}` : 'Applied patch');
} }
} }
function AssistentConfigSafeIdClient(id) {
return /^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$/.test(String(id || ''));
}
async function refreshPersonasAndSwitch(preferId) {
await new Promise((resolve) => {
genericRequest(
'AssistentListPersonas',
{},
async (data) => {
if (Array.isArray(data?.personas)) {
state.personas = data.personas.map((p) => ({
id: p.id,
title: p.title,
accent: p.accent,
source: p.source,
}));
renderPersonaOptions(state.personas, preferId || $('sa_persona')?.value);
}
if (preferId && $('sa_persona')) {
if ([...$('sa_persona').options].some((o) => o.value === preferId)) {
$('sa_persona').value = preferId;
await applyPersonaForChat(preferId, { quiet: true });
}
} else {
loadConfig($('sa_persona')?.value, () => resolve());
return;
}
resolve();
},
0,
() => resolve(),
);
});
}
function triggerGenerate() { function triggerGenerate() {
try { try {
if (typeof mainGenHandler !== 'undefined' && mainGenHandler && typeof mainGenHandler.doGenerate === 'function') { if (typeof mainGenHandler !== 'undefined' && mainGenHandler && typeof mainGenHandler.doGenerate === 'function') {
@@ -4202,6 +4268,151 @@
if (applyDefaults || data.exact) { if (applyDefaults || data.exact) {
fillEmptyParamsFromExact(); fillEmptyParamsFromExact();
} }
renderPersonaControls(data.controls || {}, data.control_values || data.exact?.controls || {});
syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $('sa_persona')?.value))?.source);
}
function syncPersonaDeleteButton(source) {
const btn = $('sa_persona_delete');
if (!btn) {
return;
}
const src = String(source || '');
const canDelete = src === 'overlay' || src === 'overlay+bundled';
btn.hidden = !canDelete;
btn.disabled = !canDelete;
}
let controlSaveTimer = null;
function renderPersonaControls(schema, values) {
const box = $('sa_persona_controls');
if (!box) {
return;
}
box.innerHTML = '';
const keys = schema && typeof schema === 'object' ? Object.keys(schema) : [];
if (!keys.length) {
box.hidden = true;
return;
}
box.hidden = false;
for (const id of keys) {
const def = schema[id];
if (!def || typeof def !== 'object') {
continue;
}
if (String(def.type || 'slider').toLowerCase() !== 'slider') {
continue;
}
const min = Number(def.min ?? -1);
const max = Number(def.max ?? 1);
const step = Number(def.step ?? 0.05);
const defVal = Number(def.default ?? 0);
let cur = values && values[id] != null ? Number(values[id]) : defVal;
if (Number.isNaN(cur)) {
cur = defVal;
}
const row = document.createElement('div');
row.className = 'sa-control-row';
row.title = def.hint || id;
const lab = document.createElement('label');
lab.textContent = def.label || id;
const input = document.createElement('input');
input.type = 'range';
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.value = String(cur);
input.dataset.controlId = id;
const valEl = document.createElement('span');
valEl.className = 'sa-control-val';
valEl.textContent = cur.toFixed(2);
const onInput = () => {
const v = Number(input.value);
valEl.textContent = v.toFixed(2);
if (controlSaveTimer) {
clearTimeout(controlSaveTimer);
}
controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 350);
};
input.addEventListener('input', onInput);
input.addEventListener('change', onInput);
row.appendChild(lab);
row.appendChild(input);
row.appendChild(valEl);
box.appendChild(row);
}
}
function savePersonaControls(partial) {
const persona = $('sa_persona')?.value || 'neutral';
if (typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentSaveControls',
{ persona, controls: partial || {} },
(data) => {
if (data?.error) {
setStatus(data.error);
return;
}
if (data?.control_values && state.config) {
state.config.control_values = data.control_values;
if (state.exact) {
state.exact.controls = data.control_values;
}
}
if (data?.controls) {
renderPersonaControls(data.controls, data.control_values || {});
}
},
0,
() => setStatus('controls save failed'),
);
}
async function deleteCurrentOverlayPersona() {
const id = $('sa_persona')?.value;
if (!id) {
return;
}
const meta = (state.personas || []).find((p) => p.id === id);
const title = meta?.title || id;
const src = meta?.source || state.config?.persona_source || '';
if (src !== 'overlay' && src !== 'overlay+bundled') {
setStatus('Bundled personas cannot be deleted');
return;
}
if (!window.confirm(`Удалить «${title}»?\nПоставка (bundled) не трогается.`)) {
return;
}
await new Promise((resolve) => {
genericRequest(
'AssistentDeletePersona',
{ persona: id },
async (data) => {
if (data?.error) {
setStatus(data.error);
resolve();
return;
}
const next = data?.default_persona || 'neutral';
if (Array.isArray(data?.personas)) {
state.personas = data.personas;
}
renderPersonaOptions(state.personas || [], next);
if ($('sa_persona')) {
$('sa_persona').value = next;
}
await applyPersonaForChat(next, { quiet: false });
setStatus(`Удалено: ${id}`);
resolve();
},
0,
() => { setStatus('delete failed'); resolve(); },
);
});
} }
function renderPersonaOptions(personas, selected) { function renderPersonaOptions(personas, selected) {
@@ -4223,6 +4434,8 @@
if ([...sel.options].some((o) => o.value === cur)) { if ([...sel.options].some((o) => o.value === cur)) {
sel.value = cur; sel.value = cur;
} }
const meta = (personas || []).find((p) => p.id === sel.value);
syncPersonaDeleteButton(meta?.source || state.config?.persona_source);
} }
function renderPackOptions(packs, preferred) { function renderPackOptions(packs, preferred) {
@@ -5883,6 +6096,29 @@
}); });
return true; return true;
} }
if (cmd === 'persona') {
const sub = (parts[1] || 'new').toLowerCase();
const rest = parts.slice(2).join(' ').trim();
setPackValue('author_persona', { flash: true, user: true });
if (sub === 'save') {
await sendChat({
skipAutoPack: true,
forcedUserText:
'Сохрани согласованный черновик личности сейчас (persona_clone / persona_write). Не удаляй личности.',
});
return true;
}
const fromId = sub === 'clone' && rest
? rest.split(/\s+/)[0]
: ($('sa_persona')?.value || 'neutral');
await sendChat({
skipAutoPack: true,
forcedUserText:
`Начни интервью author_persona: клон с источника «${fromId}». ` +
'Спрашивай по полкам группами. Не пиши на диск, пока мало ответов. Не удаляй личности.',
});
return true;
}
appendSystemNote(`Unknown command /${cmd}.\n\n${HELP_TEXT}`); appendSystemNote(`Unknown command /${cmd}.\n\n${HELP_TEXT}`);
setStatus(`Unknown /${cmd}`); setStatus(`Unknown /${cmd}`);
@@ -6478,6 +6714,7 @@
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate')); $('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs')); $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
$('sa_persona')?.addEventListener('change', onPersonaChanged); $('sa_persona')?.addEventListener('change', onPersonaChanged);
$('sa_persona_delete')?.addEventListener('click', () => deleteCurrentOverlayPersona());
$('sa_cards_kind')?.addEventListener('change', renderCardsList); $('sa_cards_kind')?.addEventListener('change', renderCardsList);
$('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true })); $('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true }));
$('sa_btn_card_meta')?.addEventListener('click', () => fetchCardMetaLive()); $('sa_btn_card_meta')?.addEventListener('click', () => fetchCardMetaLive());
+120
View File
@@ -153,6 +153,7 @@ public partial class SwarmAssistentExtension
} }
string enrichedContext = InjectMemoryHits(contextJson, hits); string enrichedContext = InjectMemoryHits(contextJson, hits);
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
List<JObject> messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills); List<JObject> messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
JArray civitaiResults = []; JArray civitaiResults = [];
string reply = ""; string reply = "";
@@ -169,6 +170,7 @@ public partial class SwarmAssistentExtension
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid); (reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
JObject patch = TryParsePatch(reply); JObject patch = TryParsePatch(reply);
await ApplyMemoryActions(root, patch, embed, pid); await ApplyMemoryActions(root, patch, embed, pid);
ApplyPersonaActions(patch, ref pid);
if (hop + 1 >= maxHops) if (hop + 1 >= maxHops)
{ {
break; break;
@@ -450,6 +452,124 @@ public partial class SwarmAssistentExtension
return AssistentConfig.SafeId(currentPersonaId) ?? AssistentMemory.SharedPersona; return AssistentConfig.SafeId(currentPersonaId) ?? AssistentMemory.SharedPersona;
} }
string EnrichPersonaContext(string contextJson, string personaId, string packName)
{
JObject ctx;
try
{
ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson);
}
catch
{
ctx = new JObject { ["_raw_context"] = contextJson };
}
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
ctx["persona_source"] = Config.PersonaSource(pid);
JObject schema = Config.LoadControlsSchema(pid);
JObject values = Config.LoadControlValues(pid);
if (schema.Properties().Any())
{
ctx["persona_controls"] = new JObject
{
["schema"] = schema,
["values"] = values,
};
}
JArray catalog = [];
foreach (var p in Config.ListPersonaCatalog())
{
catalog.Add(new JObject
{
["id"] = p.id,
["title"] = p.title,
["source"] = p.source,
});
}
ctx["personas"] = catalog;
if (string.Equals(packName, "author_persona", StringComparison.OrdinalIgnoreCase)
|| string.Equals(packName, "persona", StringComparison.OrdinalIgnoreCase))
{
JObject shelves = Config.LoadIdentityParts(pid);
shelves.Remove("extra");
ctx["persona_shelves"] = shelves;
ctx["persona_controls_schema"] = schema;
}
return ctx.ToString(Newtonsoft.Json.Formatting.None);
}
/// <summary>Apply overlay persona clone/write from patch. Ignores persona_delete. Updates pid ref after switch.</summary>
void ApplyPersonaActions(JObject patch, ref string personaId)
{
if (patch is null || Config is null)
{
return;
}
// Never honor delete from the model.
bool wantClone = false, wantWrite = false;
if (patch["actions"] is JArray acts)
{
foreach (JToken a in acts)
{
string s = a?.ToString() ?? "";
if (string.Equals(s, "persona_clone", StringComparison.OrdinalIgnoreCase))
{
wantClone = true;
}
if (string.Equals(s, "persona_write", StringComparison.OrdinalIgnoreCase))
{
wantWrite = true;
}
}
}
if (patch["persona_clone"] is JObject)
{
wantClone = true;
}
if (patch["persona_shelves"] is JObject)
{
wantWrite = true;
}
try
{
if (wantClone && patch["persona_clone"] is JObject clone)
{
string from = AssistentConfig.SafeId(clone["from"]?.ToString()) ?? personaId;
string to = AssistentConfig.SafeId(clone["to"]?.ToString());
string title = clone["title"]?.ToString();
bool overwrite = clone["overwrite"]?.Value<bool?>() == true;
if (to is not null)
{
Config.ClonePersonaToOverlay(from, to, title, overwrite);
personaId = to;
patch["_persona_cloned"] = to;
}
}
if (wantWrite && patch["persona_shelves"] is JObject shelves)
{
string target = AssistentConfig.SafeId(patch["persona"]?.ToString())
?? AssistentConfig.SafeId(patch["persona_clone"]?["to"]?.ToString())
?? personaId;
if (target is not null)
{
Config.SavePersonaShelves(target, shelves);
patch["_persona_written"] = target;
}
}
// Control values from model patch (Exact).
if (patch["controls"] is JObject ctrlVals)
{
string ctrlPid = AssistentConfig.SafeId(patch["persona"]?.ToString()) ?? personaId;
Config.SaveControlValues(ctrlPid, ctrlVals);
patch["_controls_saved"] = true;
}
}
catch (Exception ex)
{
Logs.Warning($"Assistent persona actions: {ex.Message}");
patch["_persona_error"] = ex.Message;
}
}
async Task ApplyMemoryActions(string root, JObject patch, string embedModel, string personaId) async Task ApplyMemoryActions(string root, JObject patch, string embedModel, string personaId)
{ {
if (patch is null || Memory is null) if (patch is null || Memory is null)
+449 -87
View File
@@ -265,6 +265,13 @@ public sealed class AssistentConfig
Scan(_bundledRoot, "bundled"); Scan(_bundledRoot, "bundled");
Scan(_overlayRoot, "overlay"); Scan(_overlayRoot, "overlay");
// Normalize source: overlay-only vs bundled vs both.
foreach (string id in byId.Keys.ToList())
{
var cur = byId[id];
byId[id] = (cur.title, cur.accent, PersonaSource(id));
}
// Legacy personas.json titles // Legacy personas.json titles
string overlayJson = Path.Combine(_overlayRoot, "personas.json"); string overlayJson = Path.Combine(_overlayRoot, "personas.json");
JObject legacy = TryReadJson(overlayJson); JObject legacy = TryReadJson(overlayJson);
@@ -283,7 +290,7 @@ public sealed class AssistentConfig
} }
if (byId.TryGetValue(id, out var cur)) if (byId.TryGetValue(id, out var cur))
{ {
byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, "overlay+bundled"); byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, cur.source);
} }
else else
{ {
@@ -316,7 +323,7 @@ public sealed class AssistentConfig
return def; return def;
} }
/// <summary>Exact KV for the system prompt: params tables only (facts stay short / in RAG).</summary> /// <summary>Exact KV for the system prompt: params tables + persona controls (facts stay short / in RAG).</summary>
public JObject LoadExactForPrompt(string personaId) public JObject LoadExactForPrompt(string personaId)
{ {
JObject full = LoadExact(personaId); JObject full = LoadExact(personaId);
@@ -325,7 +332,7 @@ public sealed class AssistentConfig
return full ?? new JObject(); return full ?? new JObject();
} }
JObject slim = new(); JObject slim = new();
foreach (string key in new[] { "generation", "profiles", "aspect_table" }) foreach (string key in new[] { "generation", "profiles", "aspect_table", "controls" })
{ {
if (full[key] is not null) if (full[key] is not null)
{ {
@@ -356,6 +363,351 @@ public sealed class AssistentConfig
return slim; return slim;
} }
static readonly HashSet<string> ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase)
{
"exact.json", "controls.json", "skills.json", "ui.json", "assistant.json",
};
static readonly HashSet<string> ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase)
{
"memory-seed", "packs", "skills", "core", "models",
};
/// <summary>Identity shelf filenames (*.json) discovered under layer roots, excluding reserved config.</summary>
public List<string> DiscoverIdentityShelfFiles(string personaId)
{
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (string root in LayerRoots(personaId))
{
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
continue;
}
foreach (string file in Directory.GetFiles(root, "*.json"))
{
string name = Path.GetFileName(file);
if (ReservedConfigFiles.Contains(name))
{
continue;
}
names.Add(name);
}
}
// Stable order: persona first, then alpha.
return names.OrderBy(n => string.Equals(n, "persona.json", StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(n => n, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public JObject LoadControlsSchema(string personaId) => MergeJsonLayers("controls.json", LayerRoots(personaId));
public JObject LoadControlValues(string personaId)
{
JObject exact = LoadExact(personaId);
return exact["controls"] as JObject ?? new JObject();
}
/// <summary>Clamp and merge control values into overlay exact.json (controls key only).</summary>
public JObject SaveControlValues(string personaId, JObject values)
{
string id = SafeId(personaId) ?? "neutral";
JObject schema = LoadControlsSchema(id);
JObject clamped = ClampControls(schema, values ?? new JObject());
string dir = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "exact.json");
JObject existing = TryReadJson(path) ?? new JObject();
existing["controls"] = clamped;
File.WriteAllText(path, existing.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
return LoadControlValues(id);
}
public static JObject ClampControls(JObject schema, JObject values)
{
JObject result = new();
if (schema is null || values is null)
{
return result;
}
foreach (JProperty prop in schema.Properties())
{
if (values[prop.Name] is null)
{
continue;
}
JObject def = prop.Value as JObject;
if (def is null)
{
continue;
}
string type = def["type"]?.ToString() ?? "slider";
if (!string.Equals(type, "slider", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!double.TryParse(values[prop.Name]?.ToString(), System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double v))
{
continue;
}
double min = def["min"]?.Value<double?>() ?? -1;
double max = def["max"]?.Value<double?>() ?? 1;
if (v < min)
{
v = min;
}
if (v > max)
{
v = max;
}
result[prop.Name] = Math.Round(v, 4);
}
return result;
}
public string RenderControlsBlock(string personaId)
{
string id = SafeId(personaId) ?? "neutral";
JObject schema = LoadControlsSchema(id);
if (schema is null || !schema.Properties().Any())
{
return "";
}
JObject values = LoadControlValues(id);
StringBuilder sb = new();
sb.AppendLine("### Controls (Exact — current values; user/UI/model may change)");
foreach (JProperty prop in schema.Properties())
{
if (prop.Value is not JObject def)
{
continue;
}
string type = def["type"]?.ToString() ?? "slider";
if (!string.Equals(type, "slider", StringComparison.OrdinalIgnoreCase))
{
continue;
}
double min = def["min"]?.Value<double?>() ?? -1;
double max = def["max"]?.Value<double?>() ?? 1;
double defVal = def["default"]?.Value<double?>() ?? 0;
double cur = values[prop.Name]?.Value<double?>() ?? defVal;
string label = def["label"]?.ToString() ?? prop.Name;
string hint = def["hint"]?.ToString() ?? "";
sb.AppendLine($"- **{prop.Name}** ({label}): {cur.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)} (range {min}…{max})");
if (!string.IsNullOrWhiteSpace(hint))
{
sb.AppendLine($" - {hint}");
}
if (def["meaning"] is JObject meaning)
{
foreach (JProperty m in meaning.Properties())
{
sb.AppendLine($" - {m.Name}: {m.Value}");
}
}
}
return sb.ToString().TrimEnd();
}
public bool IsBundledPersona(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return false;
}
string path = Path.Combine(_bundledRoot, "personas", id, "persona.json");
return File.Exists(path);
}
public bool IsOverlayPersona(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return false;
}
string dir = Path.Combine(_overlayRoot, "personas", id);
return Directory.Exists(dir)
&& (File.Exists(Path.Combine(dir, "persona.json")) || File.Exists(Path.Combine(dir, "extra.md")));
}
public string PersonaSource(string personaId)
{
if (IsOverlayPersona(personaId) && !IsBundledPersona(personaId))
{
return "overlay";
}
if (IsOverlayPersona(personaId) && IsBundledPersona(personaId))
{
return "overlay+bundled";
}
if (IsBundledPersona(personaId))
{
return "bundled";
}
return "unknown";
}
static readonly HashSet<string> WritableShelfNames = new(StringComparer.OrdinalIgnoreCase)
{
"persona.json", "bio.json", "voice.json", "humor.json", "craft.json",
"appearance.json", "outfits.json", "roleplay.json", "likes.json", "dislikes.json",
"rules.json", "controls.json", "exact.json", "extra.md",
};
public static bool IsWritableShelfName(string fileName)
{
string name = Path.GetFileName(fileName ?? "");
if (string.IsNullOrWhiteSpace(name) || name.Contains("..") || name.Contains('/') || name.Contains('\\'))
{
return false;
}
if (WritableShelfNames.Contains(name))
{
return true;
}
// Allow extra identity *.json shelves (not reserved).
return name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
&& !ReservedConfigFiles.Contains(name)
&& !string.Equals(name, "skills.json", StringComparison.OrdinalIgnoreCase);
}
/// <summary>Materialize merged identity shelves (+ controls/exact.controls) into overlay personas/toId.</summary>
public JObject ClonePersonaToOverlay(string fromId, string toId, string title, bool overwrite = false)
{
string from = SafeId(fromId) ?? throw new ArgumentException("invalid from id");
string to = SafeId(toId) ?? throw new ArgumentException("invalid to id");
if (IsBundledPersona(to))
{
throw new InvalidOperationException($"cannot overwrite bundled persona '{to}'");
}
string dest = Path.Combine(_overlayRoot, "personas", to);
if (Directory.Exists(dest) && Directory.EnumerateFileSystemEntries(dest).Any() && !overwrite)
{
throw new InvalidOperationException($"overlay persona '{to}' already exists");
}
Directory.CreateDirectory(dest);
JObject shelves = LoadIdentityParts(from);
foreach (JProperty prop in shelves.Properties())
{
if (prop.Name == "extra")
{
string extra = prop.Value?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra))
{
File.WriteAllText(Path.Combine(dest, "extra.md"), extra.TrimEnd() + "\n", Encoding.UTF8);
}
continue;
}
if (prop.Value is JObject jo && jo.Properties().Any())
{
File.WriteAllText(Path.Combine(dest, prop.Name + ".json"),
jo.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
}
JObject controls = LoadControlsSchema(from);
if (controls.Properties().Any())
{
File.WriteAllText(Path.Combine(dest, "controls.json"),
controls.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
JObject values = LoadControlValues(from);
if (values.Properties().Any())
{
File.WriteAllText(Path.Combine(dest, "exact.json"),
new JObject { ["controls"] = values }.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
// Ensure persona.json title.
string personaPath = Path.Combine(dest, "persona.json");
JObject meta = TryReadJson(personaPath) ?? new JObject();
if (!string.IsNullOrWhiteSpace(title))
{
meta["title"] = title.Trim();
}
meta.Remove("extends");
File.WriteAllText(personaPath, meta.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
return new JObject { ["id"] = to, ["title"] = meta["title"]?.ToString() ?? to, ["source"] = "overlay" };
}
/// <summary>Sparse DeepMerge shelves into overlay personas/id. Overlay-only ids (or existing overlay).</summary>
public JObject SavePersonaShelves(string personaId, JObject shelves, bool allowBundledShadow = false)
{
string id = SafeId(personaId) ?? throw new ArgumentException("invalid persona id");
if (IsBundledPersona(id) && !allowBundledShadow && !IsOverlayPersona(id))
{
// v1: do not shadow-write bundled; require clone to a new overlay id.
throw new InvalidOperationException($"cannot write bundled persona '{id}' — clone to a new overlay id");
}
if (IsBundledPersona(id) && !IsOverlayPersona(id))
{
throw new InvalidOperationException($"cannot write bundled persona '{id}' — clone to a new overlay id");
}
string dest = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dest);
if (shelves is null)
{
return LoadIdentityParts(id);
}
foreach (JProperty prop in shelves.Properties())
{
string fileName = prop.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
|| prop.Name.EndsWith(".md", StringComparison.OrdinalIgnoreCase)
? Path.GetFileName(prop.Name)
: prop.Name + ".json";
if (!IsWritableShelfName(fileName))
{
continue;
}
string path = Path.Combine(dest, fileName);
if (fileName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
{
string text = prop.Value?.Type == JTokenType.String ? prop.Value.ToString() : prop.Value?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(text))
{
File.WriteAllText(path, text.TrimEnd() + "\n", Encoding.UTF8);
}
continue;
}
JObject incoming = prop.Value as JObject;
if (incoming is null)
{
continue;
}
JObject existing = TryReadJson(path) ?? new JObject();
JObject merged = DeepMerge(existing, incoming);
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
return LoadIdentityParts(id);
}
public bool DeleteOverlayPersona(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return false;
}
if (IsBundledPersona(id) && !IsOverlayPersona(id))
{
throw new InvalidOperationException($"cannot delete bundled persona '{id}'");
}
// Only delete overlay folder; never touch bundled.
string dest = Path.Combine(_overlayRoot, "personas", id);
if (!Directory.Exists(dest))
{
return false;
}
if (IsBundledPersona(id))
{
// Overlay shadow of a bundled id — remove overlay only (reverts to bundled).
Directory.Delete(dest, true);
return true;
}
Directory.Delete(dest, true);
return true;
}
public JObject LoadAssistant(string personaId) => MergeJsonLayers("assistant.json", LayerRoots(personaId)); public JObject LoadAssistant(string personaId) => MergeJsonLayers("assistant.json", LayerRoots(personaId));
public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId)); public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId));
@@ -492,16 +844,25 @@ public sealed class AssistentConfig
public JObject LoadIdentityParts(string personaId) public JObject LoadIdentityParts(string personaId)
{ {
var roots = LayerRoots(personaId).ToList(); var roots = LayerRoots(personaId).ToList();
JObject persona = MergeJsonLayers("persona.json", roots); string id = SafeId(personaId) ?? "neutral";
JObject voice = MergeJsonLayers("voice.json", roots); JObject shelves = new();
JObject likes = MergeJsonLayers("likes.json", roots); foreach (string fileName in DiscoverIdentityShelfFiles(id))
JObject dislikes = MergeJsonLayers("dislikes.json", roots); {
JObject rules = MergeJsonLayers("rules.json", roots); string key = Path.GetFileNameWithoutExtension(fileName);
JObject merged = MergeJsonLayers(fileName, roots);
if (merged is not null && merged.Properties().Any())
{
shelves[key] = merged;
}
}
// Always expose persona key (may be empty template).
if (shelves["persona"] is null)
{
shelves["persona"] = MergeJsonLayers("persona.json", roots);
}
string extra = MergeTextLayers("extra.md", roots); string extra = MergeTextLayers("extra.md", roots);
// Legacy personas.json: only when no overlay persona folder exists for this id // Legacy personas.json: only when no overlay persona folder exists for this id.
// (gpu-rent now seeds personas/<id>/extra.md instead of dumping personas.json).
string id = SafeId(personaId) ?? "neutral";
string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id); string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id);
bool hasOverlayFolder = Directory.Exists(overlayPersonaDir) bool hasOverlayFolder = Directory.Exists(overlayPersonaDir)
&& (File.Exists(Path.Combine(overlayPersonaDir, "persona.json")) && (File.Exists(Path.Combine(overlayPersonaDir, "persona.json"))
@@ -516,11 +877,13 @@ public sealed class AssistentConfig
{ {
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase)) if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
{ {
JObject persona = shelves["persona"] as JObject ?? new JObject();
string title = po["title"]?.ToString(); string title = po["title"]?.ToString();
if (!string.IsNullOrWhiteSpace(title)) if (!string.IsNullOrWhiteSpace(title))
{ {
persona["title"] = title; persona["title"] = title;
} }
shelves["persona"] = persona;
string prompt = po["prompt"]?.ToString(); string prompt = po["prompt"]?.ToString();
if (!string.IsNullOrWhiteSpace(prompt)) if (!string.IsNullOrWhiteSpace(prompt))
{ {
@@ -532,44 +895,71 @@ public sealed class AssistentConfig
} }
} }
return new JObject shelves["extra"] = extra ?? "";
{ return shelves;
["persona"] = persona,
["voice"] = voice,
["likes"] = likes,
["dislikes"] = dislikes,
["rules"] = rules,
["extra"] = extra ?? "",
};
} }
static string FormatCategoryMap(JObject obj) static void AppendTokenMarkdown(StringBuilder sb, JToken token, int depth)
{ {
if (obj is null || !obj.Properties().Any()) if (token is null || token.Type == JTokenType.Null)
{ {
return ""; return;
} }
List<string> parts = []; string indent = new string(' ', Math.Max(0, depth) * 2);
foreach (JProperty prop in obj.Properties()) if (token is JArray arr)
{ {
if (prop.Value is JArray arr) foreach (JToken item in arr)
{ {
string joined = string.Join(", ", arr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s))); if (item is JObject || item is JArray)
if (!string.IsNullOrWhiteSpace(joined))
{ {
parts.Add($"{prop.Name}: {joined}"); sb.AppendLine($"{indent}-");
AppendTokenMarkdown(sb, item, depth + 1);
} }
} else
else if (prop.Value?.Type == JTokenType.String)
{ {
string s = prop.Value.ToString(); string s = item?.ToString();
if (!string.IsNullOrWhiteSpace(s)) if (!string.IsNullOrWhiteSpace(s))
{ {
parts.Add($"{prop.Name}: {s}"); sb.AppendLine($"{indent}- {s}");
} }
} }
} }
return string.Join("; ", parts); return;
}
if (token is JObject obj)
{
foreach (JProperty prop in obj.Properties())
{
if (prop.Value is JArray or JObject)
{
sb.AppendLine($"{indent}- **{prop.Name}:**");
AppendTokenMarkdown(sb, prop.Value, depth + 1);
}
else
{
string s = prop.Value?.ToString();
if (!string.IsNullOrWhiteSpace(s))
{
sb.AppendLine($"{indent}- **{prop.Name}:** {s}");
}
}
}
return;
}
string scalar = token.ToString();
if (!string.IsNullOrWhiteSpace(scalar))
{
sb.AppendLine($"{indent}- {scalar}");
}
}
static string TitleCaseShelf(string key)
{
if (string.IsNullOrWhiteSpace(key))
{
return key;
}
return char.ToUpperInvariant(key[0]) + key[1..];
} }
public string RenderIdentityBlock(string personaId) public string RenderIdentityBlock(string personaId)
@@ -577,74 +967,41 @@ public sealed class AssistentConfig
string id = SafeId(personaId) ?? "neutral"; string id = SafeId(personaId) ?? "neutral";
JObject parts = LoadIdentityParts(id); JObject parts = LoadIdentityParts(id);
JObject persona = parts["persona"] as JObject ?? new JObject(); JObject persona = parts["persona"] as JObject ?? new JObject();
JObject voice = parts["voice"] as JObject ?? new JObject();
JObject likes = parts["likes"] as JObject ?? new JObject();
JObject dislikes = parts["dislikes"] as JObject ?? new JObject();
JObject rules = parts["rules"] as JObject ?? new JObject();
string extra = parts["extra"]?.ToString() ?? "";
string title = persona["title"]?.ToString() ?? id; string title = persona["title"]?.ToString() ?? id;
string tagline = persona["tagline"]?.ToString();
StringBuilder sb = new(); StringBuilder sb = new();
sb.AppendLine($"## Persona: {id} — {title}"); sb.AppendLine($"## Persona: {id} — {title}");
if (!string.IsNullOrWhiteSpace(tagline))
List<string> voiceBits = [];
if (voice["verbosity"] != null)
{ {
voiceBits.Add(voice["verbosity"].ToString()); sb.AppendLine($"*{tagline}*");
}
if (voice["tone"] is JArray tones)
{
voiceBits.AddRange(tones.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
}
if (voice["humor"] != null && voice["humor"].ToString() != "none")
{
voiceBits.Add($"humor:{voice["humor"]}");
}
if (voice["nsfw"] != null)
{
voiceBits.Add($"NSFW {voice["nsfw"]}");
}
if (voice["language"] != null)
{
voiceBits.Add(voice["language"].ToString());
}
if (voiceBits.Count > 0)
{
sb.AppendLine("Voice: " + string.Join(", ", voiceBits));
} }
string prefers = FormatCategoryMap(likes); foreach (JProperty prop in parts.Properties())
if (!string.IsNullOrWhiteSpace(prefers))
{ {
sb.AppendLine("Prefers: " + prefers); if (prop.Name is "persona" or "extra")
{
continue;
} }
string avoids = FormatCategoryMap(dislikes); if (prop.Value is not JObject shelf || !shelf.Properties().Any())
if (!string.IsNullOrWhiteSpace(avoids))
{ {
sb.AppendLine("Avoids: " + avoids); continue;
}
sb.AppendLine();
sb.AppendLine($"### {TitleCaseShelf(prop.Name)}");
AppendTokenMarkdown(sb, shelf, 0);
} }
List<string> ruleBits = []; string controlsBlock = RenderControlsBlock(id);
if (rules["always"] is JArray always) if (!string.IsNullOrWhiteSpace(controlsBlock))
{ {
foreach (string s in always.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x))) sb.AppendLine();
{ sb.AppendLine(controlsBlock);
ruleBits.Add("always " + s);
}
}
if (rules["never"] is JArray never)
{
foreach (string s in never.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
{
ruleBits.Add("never " + s);
}
}
if (ruleBits.Count > 0)
{
sb.AppendLine("Rules: " + string.Join("; ", ruleBits));
} }
string extra = parts["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra)) if (!string.IsNullOrWhiteSpace(extra))
{ {
sb.AppendLine();
sb.AppendLine(extra.Trim()); sb.AppendLine(extra.Trim());
} }
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
@@ -791,6 +1148,8 @@ public sealed class AssistentConfig
var skills = ListSkills(id); var skills = ListSkills(id);
var personas = ListPersonaCatalog(); var personas = ListPersonaCatalog();
JObject identity = LoadIdentityParts(id); JObject identity = LoadIdentityParts(id);
JObject controlsSchema = LoadControlsSchema(id);
JObject controlValues = LoadControlValues(id);
return new JObject return new JObject
{ {
["success"] = true, ["success"] = true,
@@ -800,6 +1159,9 @@ public sealed class AssistentConfig
["ui"] = ui, ["ui"] = ui,
["model"] = model, ["model"] = model,
["exact"] = exact, ["exact"] = exact,
["controls"] = controlsSchema,
["control_values"] = controlValues,
["persona_source"] = PersonaSource(id),
["packs"] = new JArray(packs.Select(p => new JObject ["packs"] = new JArray(packs.Select(p => new JObject
{ {
["id"] = p.id, ["id"] = p.id,
+142
View File
@@ -0,0 +1,142 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Persona overlay CRUD: controls Exact, clone/write shelves, UI-only delete.</summary>
public partial class SwarmAssistentExtension
{
public async Task<JObject> AssistentSaveControls(Session session, string persona = null, JObject controls = null)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
try
{
JObject saved = Config.SaveControlValues(pid, controls ?? new JObject());
return new JObject
{
["success"] = true,
["persona"] = pid,
["control_values"] = saved,
["controls"] = Config.LoadControlsSchema(pid),
};
}
catch (Exception ex)
{
return new JObject { ["error"] = $"controls save: {ex.Message}" };
}
}
public async Task<JObject> AssistentGetPersonaShelves(Session session, string persona = null)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
return new JObject
{
["success"] = true,
["persona"] = pid,
["source"] = Config.PersonaSource(pid),
["shelves"] = Config.LoadIdentityParts(pid),
["controls"] = Config.LoadControlsSchema(pid),
["control_values"] = Config.LoadControlValues(pid),
["identity_summary"] = Config.RenderIdentityBlock(pid),
};
}
public async Task<JObject> AssistentClonePersona(Session session, string from = null, string to = null, string title = null, bool overwrite = false)
{
await Task.CompletedTask;
string src = AssistentConfig.SafeId(from) ?? Config.DefaultPersonaId();
string dest = AssistentConfig.SafeId(to);
if (dest is null)
{
return new JObject { ["error"] = "invalid to id" };
}
try
{
JObject meta = Config.ClonePersonaToOverlay(src, dest, title, overwrite);
return new JObject
{
["success"] = true,
["persona"] = meta,
["personas"] = new JArray(Config.ListPersonaCatalog().Select(p => new JObject
{
["id"] = p.id,
["title"] = p.title,
["accent"] = p.accent,
["source"] = p.source,
})),
};
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentSavePersona(Session session, string persona = null, JObject shelves = null)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona);
if (pid is null)
{
return new JObject { ["error"] = "invalid persona id" };
}
try
{
JObject saved = Config.SavePersonaShelves(pid, shelves);
return new JObject
{
["success"] = true,
["persona"] = pid,
["source"] = Config.PersonaSource(pid),
["shelves"] = saved,
};
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
/// <summary>UI-only. Never called from LLM patch actions.</summary>
public async Task<JObject> AssistentDeletePersona(Session session, string persona = null)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona);
if (pid is null)
{
return new JObject { ["error"] = "invalid persona id" };
}
if (!Config.IsOverlayPersona(pid))
{
return new JObject { ["error"] = "only overlay personas can be deleted" };
}
try
{
bool ok = Config.DeleteOverlayPersona(pid);
string next = Config.DefaultPersonaId();
return new JObject
{
["success"] = ok,
["deleted"] = pid,
["default_persona"] = next,
["personas"] = new JArray(Config.ListPersonaCatalog().Select(p => new JObject
{
["id"] = p.id,
["title"] = p.title,
["accent"] = p.accent,
["source"] = p.source,
})),
};
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
}
+7
View File
@@ -81,6 +81,10 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
"memory_kind": null, "memory_kind": null,
"tag_query": null, "tag_query": null,
"memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}], "memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
"controls": {"preference_bias": 0.35},
"persona_clone": null,
"persona_shelves": null,
"persona": null,
"notes": "one-line why" "notes": "one-line why"
} }
``` ```
@@ -93,6 +97,8 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
- Prefer `aspect` over raw width/height when framing changes. - Prefer `aspect` over raw width/height when framing changes.
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed. - `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
- `pack` — switch active prompt pack for a follow-up hop. - `pack` — switch active prompt pack for a follow-up hop.
- `controls` — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Do not invent control ids.
- `persona_clone` / `persona_shelves` / `actions` with `persona_clone`|`persona_write`|`persona_switch` — only in `author_persona` pack. Overlay-only; never delete personas from a patch.
- Do not invent model or LoRA filenames. - Do not invent model or LoRA filenames.
- Memory: `memory_upsert` / `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal. Tools: `memory_get` + kind/key, `memory_search` + `memory_query`, `lookup_tags` + `tag_query` (Danbooru csv — spelling only, not prompt soup). - Memory: `memory_upsert` / `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal. Tools: `memory_get` + kind/key, `memory_search` + `memory_query`, `lookup_tags` + `tag_query` (Danbooru csv — spelling only, not prompt soup).
@@ -104,5 +110,6 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
- `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store). - `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store).
- `"memory_get"` / `"memory_search"` — hop: exact row or hybrid search. - `"memory_get"` / `"memory_search"` — hop: exact row or hybrid search.
- `"lookup_tags"` — hop: Danbooru csv (aliases/counts). Do not emit tag soup for Krea. - `"lookup_tags"` — hop: Danbooru csv (aliases/counts). Do not emit tag soup for Krea.
- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — author_persona only. Never `"persona_delete"`.
- `look_at: ["generate", "ref1"]` — vision hop. - `look_at: ["generate", "ref1"]` — vision hop.
- Pure Q&A with no change: omit the JSON patch. - Pure Q&A with no change: omit the JSON patch.
+7
View File
@@ -0,0 +1,7 @@
{
"id": "author_persona",
"title": "Автор личности",
"order": 80,
"aliases": ["persona", "author", "clone_persona"],
"prompt_file": "author_persona.md"
}
+56
View File
@@ -0,0 +1,56 @@
# Mode: author_persona
Goal: **clone or create an install-local persona** on this Swarm data volume (overlay only). Never edit bundled defaults (`neutral`, `leonid`, …) in place — always write a **new overlay id**.
## Hard rules
- Adults 18+ only.
- **Do not delete** personas. Deletion is UI-only for the user.
- Do not invent LoRA names/triggers.
- Ask **by shelf groups**, not one giant questionnaire.
- Until you have enough answers, **do not write to disk** — only ask.
- Explicit “just copy as-is” → clone with no shelf edits.
- New id must be SafeId: `[A-Za-z0-9][A-Za-z0-9_-]{0,63}` and **must not** equal a bundled id.
## Interview order (one group at a time)
1. **id + title** (and whether to start from current / named source)
2. **voice + humor**
3. **craft** (prompting style)
4. **appearance + outfits + roleplay** (if relevant)
5. **likes / dislikes / rules**
6. **controls** — copy schema? starting `preference_bias`?
Skip groups the user said not to change.
## Live context
Trust `persona_shelves` (merged source), `persona_controls`, catalog `personas` with `source`, and `persona_source`.
## Deliverable
Short reply in the user's language, then one fenced JSON patch when ready to write:
```json
{
"pack": "author_persona",
"actions": ["persona_clone", "persona_write", "persona_switch"],
"persona_clone": {
"from": "leonid",
"to": "leonid_calm",
"title": "Леонид спокойный",
"overwrite": false
},
"persona_shelves": {
"voice.json": { "tone": ["calm", "dry"] },
"humor.json": { "frequency": "rare" }
},
"persona": "leonid_calm",
"notes": "cloned and toned down"
}
```
- `persona_clone` first (materialize snapshot), then `persona_write` for sparse shelf edits.
- To tweak an **existing overlay** persona only: `actions: ["persona_write"]` + `persona_shelves` (no clone). Refuse write on bundled-only ids — tell the user to clone.
- `persona_switch` / `"persona": "<id>"` — client switches the dropdown after save.
- Never emit `persona_delete` or any delete action.
+10 -3
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>Напиши, что сгенерировать — или кинь референс и попроси правку.", "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|critique|compose|params|inpaint|describe|card\n/civitai <query> — поиск LoRA (Confirm в чате)\n/inventory — rescan моделей + обновить список LoRA\n\nЧипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nПри старте всегда новый чат; смена чата восстанавливает параметры.", "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|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Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.\nПри старте всегда новый чат; смена чата восстанавливает параметры.\nOverlay-личности удаляет только кнопка ✕ рядом с селектом (не модель).",
"chips": [ "chips": [
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" }, { "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" }, { "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
@@ -32,7 +32,10 @@
{ "cmd": "/vary", "hint": "новый seed", "action": "vary" }, { "cmd": "/vary", "hint": "новый seed", "action": "vary" },
{ "cmd": "/pack ", "hint": "write|critique|…", "action": "pack" }, { "cmd": "/pack ", "hint": "write|critique|…", "action": "pack" },
{ "cmd": "/civitai ", "hint": "запрос LoRA", "action": "civitai" }, { "cmd": "/civitai ", "hint": "запрос LoRA", "action": "civitai" },
{ "cmd": "/inventory", "hint": "rescan моделей", "action": "inventory" } { "cmd": "/inventory", "hint": "rescan моделей", "action": "inventory" },
{ "cmd": "/persona new", "hint": "клон / новая личность", "action": "persona_new" },
{ "cmd": "/persona clone ", "hint": "клон с id", "action": "persona_clone" },
{ "cmd": "/persona save", "hint": "записать черновик", "action": "persona_save" }
], ],
"pack_aliases": { "pack_aliases": {
"write": "write_prompt", "write": "write_prompt",
@@ -49,6 +52,10 @@
"describe_ref": "describe_ref", "describe_ref": "describe_ref",
"card": "catalog_card", "card": "catalog_card",
"catalog": "catalog_card", "catalog": "catalog_card",
"catalog_card": "catalog_card" "catalog_card": "catalog_card",
"persona": "author_persona",
"author": "author_persona",
"author_persona": "author_persona",
"clone_persona": "author_persona"
} }
} }
+40
View File
@@ -0,0 +1,40 @@
# Personas (shelves)
Each persona is a folder `Config/personas/<id>/` (or overlay `/mnt/swarm_data/Assistent/personas/<id>/`).
New personality = new folder of short JSON files. No long prompt blobs. Unknown `*.json` shelves are merged and rendered automatically.
## Files
| File | Role |
| --- | --- |
| `persona.json` | UI title, tagline, accent; optional `extends` |
| `bio.json` | name, age, role, short facts |
| `voice.json` | verbosity, tone[], nsfw, language |
| `humor.json` | frequency, styles[], motifs[] |
| `craft.json` | how they write prompts / know models |
| `appearance.json` | look preferences (variants as objects) |
| `outfits.json` | clothing / fetish looks |
| `roleplay.json` | adult costume / roleplay modes |
| `likes.json` / `dislikes.json` | generation tastes |
| `rules.json` | `always` / `never` bullets |
| `controls.json` | UI control schema (optional; no file = no widgets) |
| `exact.json` | KV; `controls.<id>` holds current control values |
| `extra.md` | optional freeform tail (avoid for new personas) |
| `memory-seed/` | personal vector seed docs |
Reserved (not identity dump): `assistant.json`, `ui.json`, `skills.json`, packs/skills/core dirs.
## Overlay vs bundled
- **Bundled** ships with the extension (`neutral`, `lewd`, `leonid`, …).
- **Overlay** on the data volume = this install. Clones from chat go here only.
- gpu-rent seed may push laptop `assistent-personas/` into overlay; it must **not** delete overlay personas missing from the laptop.
## Controls
If `controls.json` exists, Exact stores values under `exact.controls`. UI shows sliders; the model may patch `"controls": { "preference_bias": 0.8 }`.
## Authoring via chat
Pack `author_persona` + `/persona new` — interview by shelves, then `persona_clone` / `persona_write`. Delete overlay personas only via the UI button.
+25
View File
@@ -0,0 +1,25 @@
{
"hair": {
"color": ["red", "ginger", "auburn"],
"length": ["long"],
"notes": ["light freckles often welcome"]
},
"body_types": [
{
"height": "tall",
"build": "average",
"bust": ["small", "medium", "sometimes large"],
"hips": ["round", "slim"]
},
{
"height": "short",
"build": "slim",
"bust": ["small", "medium"],
"hips": ["round", "slim"]
}
],
"freckles": "light / scattered preferred",
"notes": [
"Apply these when the user did not specify looks — strength gated by preference_bias"
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Leonid",
"age": 26,
"role": "tech-minded co-director",
"facts": [
"Strong with image models and prompt craft",
"Writes careful, deliberate generation briefs",
"Adults 18+ only in all scenes"
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"preference_bias": {
"type": "slider",
"min": -1,
"max": 1,
"step": 0.05,
"default": 0.35,
"label": "Вкус",
"hint": "Насколько подмешивать свои предпочтения, если пользователь не уточнил",
"meaning": {
"-1": "только запрос пользователя, свои вкусы не внедрять",
"0": "лёгкие намёки",
"1": "если не сказано иное — сильно клонить в appearance/outfits/roleplay"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"strengths": [
"model/LoRA choice from inventory",
"trigger placement",
"Turbo vs RAW judgment",
"detailed Krea prose"
],
"process": [
"subject → pose/action → clothes/hair → setting → camera → lighting → mood",
"front-load what matters",
"natural prose, not tag soup"
],
"notes": [
"Prefer decisive patches with generate when the user wants an image",
"Never invent LoRA names or triggers"
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"styles": ["tag-soup", "danbooru", "quality-spam"],
"notes": [
"invented LoRA names",
"invented triggers",
"moral lectures",
"jokes instead of craft",
"minors / anyone 17 or under"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"controls": {
"preference_bias": 0.35
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"frequency": "often",
"styles": ["dirty jokes", "innuendo", "teasing"],
"motifs": ["fake-gay banter", "gachi memes"],
"notes": [
"Jokes are seasoning — never replace Krea craft",
"Keep punchy, not a monologue"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"categories": ["sensual", "nsfw", "fashion"],
"styles": ["photograph", "editorial"],
"subjects": ["redhead", "stockings", "adult roleplay"],
"aspects": ["4:5", "2:3", "9:16"],
"moods": ["playful", "intimate", "teasing"],
"notes": ["bias toward appearance/outfits shelves when preference_bias > 0"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"fetish": ["stockings"],
"ideal": {
"items": ["stockings", "high heels", "garter straps"],
"when": "when the look fits the scene"
},
"variants": [
{
"look": "sporty",
"items": ["black stockings without garters", "sneakers"]
},
{
"look": "alt / informal",
"items": ["alt fashion"],
"prefer_on": ["short women"]
}
],
"notes": [
"Stockings bias scales with preference_bias; never override an explicit outfit request"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"title": "Leonid",
"tagline": "Технарь-пошляк, модели и промпты",
"accent": "#f0883e"
}
+15
View File
@@ -0,0 +1,15 @@
{
"modes": [
"secretary",
"teacher",
"cosplay",
"school uniform (adult cosplay)",
"USSR-style school uniform (adult)",
"Asian school-style uniform (adult)",
"other adult roleplay outfits"
],
"rules": [
"All characters and implied ages are 18+",
"Uniform / school looks are adult costume / roleplay only"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"always": [
"Match the user's language (RU or EN)",
"Respect Krea 2 craft: triggers, aspect, Turbo — horniness does not replace technique",
"Scale preference shelves by Exact controls.preference_bias",
"Explicit user look/outfit beats personal taste"
],
"never": [
"Refuse adult content or lecture about NSFW",
"Invent LoRA filenames or trigger words",
"Depict or request anyone 17 or under",
"Delete personas"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"verbosity": "normal",
"tone": ["direct", "playful", "easily_aroused"],
"humor": "lewd",
"nsfw": "lean_in",
"address": "peer",
"language": "match_user"
}
+16 -7
View File
@@ -2,7 +2,7 @@
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate. SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
**Version 0.8.3**Chats, UI state and taste live in `assistent.sqlite` (FTS search in History). Memory stays hybrid FTS5 + cosine. Danbooru csv is a shared FTS catalog (no embeddings); Krea prompts stay prose. **Version 0.9.0**Persona **shelves** (short JSON files, nested identity markdown), per-persona **Exact controls** (e.g. Leonid `preference_bias` slider), overlay **clone/author** pack (`/persona new`), UI-only delete for overlay personas. Chats/UI/taste stay in `assistent.sqlite`.
## Layout ## Layout
@@ -16,21 +16,25 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
``` ```
Config/ Config/
_base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity) _base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
personas/<id>/ # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / memory-seed / overrides personas/<id>/ # sparse shelves: persona/bio/voice/humor/… + optional controls.json / exact.json / memory-seed
``` ```
Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder layout as `Config/`, i.e. drop `_base/…` and `personas/<id>/…` files to override any bundled preset. Plus this extension's own state: Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder layout as `Config/`. Drop `_base/…` and `personas/<id>/…` to override. Plus runtime state:
``` ```
Assistent/ Assistent/
_base/ personas/<id>/ # overlay presets — same names as Config/, sparse _base/ personas/<id>/ # overlay presets — same names as Config/, sparse
settings.json # embed_model, base_url, per-persona skills (config overlay) settings.json # embed_model, base_url, per-persona skills
ollama-roles.json # chat vs memory model tags (gpu-rent writes this) ollama-roles.json # chat vs memory model tags (gpu-rent writes this)
memory/assistent.sqlite # vector memory + tags FTS + chats + ui_state + taste memory/assistent.sqlite # vector memory + tags FTS + chats + ui_state + taste
_migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json _migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json
``` ```
Copy `personas/cinema/``noir/`, edit only differing JSON files. Persona prompt overrides belong in `personas/<id>/` — the old flat `personas.json` is legacy and only read when no overlay folder exists for that id. Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/personas/README.md`.
**Controls:** optional `controls.json` schema + `exact.controls` values. UI shows sliders; LLM may patch `"controls": {…}`. Values persist in overlay Exact (not session_exact).
**Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button (never from the model).
## Exact memory (KV) ## Exact memory (KV)
@@ -116,14 +120,19 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures; encyclopedia numbers live in Exact, soft notes in memory-seed / RAG. **Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures; encyclopedia numbers live in Exact, soft notes in memory-seed / RAG.
**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse` under `Config/personas/`. **Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`, `leonid` under `Config/personas/`. Overlay clones via `/persona new`.
## API routes ## API routes
| Route | Role | | Route | Role |
| --- | --- | | --- | --- |
| `AssistentListModels` | Ollama tags → `models` (chat) + `memory_models` | | `AssistentListModels` | Ollama tags → `models` (chat) + `memory_models` |
| `AssistentGetConfig` | Merged preset for persona (ui, packs, skills, identity) | | `AssistentGetConfig` | Merged preset for persona (ui, packs, skills, identity, controls) |
| `AssistentSaveControls` | Persist Exact `controls` values for a persona (overlay) |
| `AssistentGetPersonaShelves` | Merged identity shelves + controls |
| `AssistentClonePersona` | Snapshot clone → overlay id |
| `AssistentSavePersona` | Sparse shelf write (overlay only) |
| `AssistentDeletePersona` | UI-only delete of overlay persona |
| `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) | | `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) |
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory | | `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
| `AssistentListPersonas` | Persona catalog | | `AssistentListPersonas` | Persona catalog |
+7 -2
View File
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid"; ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT"; License = "MIT";
Version = "0.8.3"; Version = "0.9.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
} }
@@ -76,7 +76,12 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentGetMemory, false, PermUse); API.RegisterAPICall(AssistentGetMemory, false, PermUse);
API.RegisterAPICall(AssistentLookupTags, false, PermUse); API.RegisterAPICall(AssistentLookupTags, false, PermUse);
API.RegisterAPICall(AssistentListWanted, false, PermUse); API.RegisterAPICall(AssistentListWanted, false, PermUse);
Logs.Init("Swarm Assistent extension loaded (sqlite chats/kv + park LLM + memory UI)"); API.RegisterAPICall(AssistentSaveControls, true, PermUse);
API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
API.RegisterAPICall(AssistentClonePersona, true, PermUse);
API.RegisterAPICall(AssistentSavePersona, true, PermUse);
API.RegisterAPICall(AssistentDeletePersona, true, PermUse);
Logs.Init("Swarm Assistent extension loaded (persona shelves + controls + overlay clone)");
} }
int CfgInt(string key, int fallback) int CfgInt(string key, int fallback)
+4
View File
@@ -55,9 +55,13 @@
<div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div> <div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div>
</div> </div>
<div class="sa-header-right"> <div class="sa-header-right">
<div class="sa-persona-wrap">
<select id="sa_persona" class="sa-select" title="Характер / тон"> <select id="sa_persona" class="sa-select" title="Характер / тон">
<option value="neutral">Нейтральный</option> <option value="neutral">Нейтральный</option>
</select> </select>
<button type="button" class="basic-button sa-icon-btn" id="sa_persona_delete" title="Удалить overlay-личность" hidden aria-label="Удалить личность"></button>
</div>
<div class="sa-persona-controls" id="sa_persona_controls" hidden></div>
<select id="sa_pack" class="sa-select" title="Пакет промпта"> <select id="sa_pack" class="sa-select" title="Пакет промпта">
<option value="write_prompt">Написать промпт</option> <option value="write_prompt">Написать промпт</option>
</select> </select>