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:
@@ -669,9 +669,59 @@
|
||||
.sa-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
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 {
|
||||
min-width: 2rem;
|
||||
padding-left: 0.45rem;
|
||||
|
||||
+238
-1
@@ -2150,6 +2150,8 @@
|
||||
}
|
||||
}
|
||||
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)');
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
syncLiveParamsBar();
|
||||
syncBuildGenButton();
|
||||
@@ -3224,10 +3254,46 @@
|
||||
}
|
||||
}
|
||||
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() {
|
||||
try {
|
||||
if (typeof mainGenHandler !== 'undefined' && mainGenHandler && typeof mainGenHandler.doGenerate === 'function') {
|
||||
@@ -4202,6 +4268,151 @@
|
||||
if (applyDefaults || data.exact) {
|
||||
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) {
|
||||
@@ -4223,6 +4434,8 @@
|
||||
if ([...sel.options].some((o) => o.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) {
|
||||
@@ -5883,6 +6096,29 @@
|
||||
});
|
||||
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}`);
|
||||
setStatus(`Unknown /${cmd}`);
|
||||
@@ -6478,6 +6714,7 @@
|
||||
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
|
||||
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
|
||||
$('sa_persona')?.addEventListener('change', onPersonaChanged);
|
||||
$('sa_persona_delete')?.addEventListener('click', () => deleteCurrentOverlayPersona());
|
||||
$('sa_cards_kind')?.addEventListener('change', renderCardsList);
|
||||
$('sa_btn_cards_refresh')?.addEventListener('click', () => refreshInventory(() => renderCardsList(), { rescan: true }));
|
||||
$('sa_btn_card_meta')?.addEventListener('click', () => fetchCardMetaLive());
|
||||
|
||||
@@ -153,6 +153,7 @@ public partial class SwarmAssistentExtension
|
||||
}
|
||||
|
||||
string enrichedContext = InjectMemoryHits(contextJson, hits);
|
||||
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
|
||||
List<JObject> messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
|
||||
JArray civitaiResults = [];
|
||||
string reply = "";
|
||||
@@ -169,6 +170,7 @@ public partial class SwarmAssistentExtension
|
||||
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
||||
JObject patch = TryParsePatch(reply);
|
||||
await ApplyMemoryActions(root, patch, embed, pid);
|
||||
ApplyPersonaActions(patch, ref pid);
|
||||
if (hop + 1 >= maxHops)
|
||||
{
|
||||
break;
|
||||
@@ -450,6 +452,124 @@ public partial class SwarmAssistentExtension
|
||||
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)
|
||||
{
|
||||
if (patch is null || Memory is null)
|
||||
|
||||
+448
-86
@@ -265,6 +265,13 @@ public sealed class AssistentConfig
|
||||
Scan(_bundledRoot, "bundled");
|
||||
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
|
||||
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
||||
JObject legacy = TryReadJson(overlayJson);
|
||||
@@ -283,7 +290,7 @@ public sealed class AssistentConfig
|
||||
}
|
||||
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
|
||||
{
|
||||
@@ -316,7 +323,7 @@ public sealed class AssistentConfig
|
||||
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)
|
||||
{
|
||||
JObject full = LoadExact(personaId);
|
||||
@@ -325,7 +332,7 @@ public sealed class AssistentConfig
|
||||
return full ?? new JObject();
|
||||
}
|
||||
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)
|
||||
{
|
||||
@@ -356,6 +363,351 @@ public sealed class AssistentConfig
|
||||
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 LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId));
|
||||
@@ -492,16 +844,25 @@ public sealed class AssistentConfig
|
||||
public JObject LoadIdentityParts(string personaId)
|
||||
{
|
||||
var roots = LayerRoots(personaId).ToList();
|
||||
JObject persona = MergeJsonLayers("persona.json", roots);
|
||||
JObject voice = MergeJsonLayers("voice.json", roots);
|
||||
JObject likes = MergeJsonLayers("likes.json", roots);
|
||||
JObject dislikes = MergeJsonLayers("dislikes.json", roots);
|
||||
JObject rules = MergeJsonLayers("rules.json", roots);
|
||||
string id = SafeId(personaId) ?? "neutral";
|
||||
JObject shelves = new();
|
||||
foreach (string fileName in DiscoverIdentityShelfFiles(id))
|
||||
{
|
||||
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);
|
||||
|
||||
// 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";
|
||||
// Legacy personas.json: only when no overlay persona folder exists for this id.
|
||||
string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id);
|
||||
bool hasOverlayFolder = Directory.Exists(overlayPersonaDir)
|
||||
&& (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))
|
||||
{
|
||||
JObject persona = shelves["persona"] as JObject ?? new JObject();
|
||||
string title = po["title"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
persona["title"] = title;
|
||||
}
|
||||
shelves["persona"] = persona;
|
||||
string prompt = po["prompt"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(prompt))
|
||||
{
|
||||
@@ -532,44 +895,71 @@ public sealed class AssistentConfig
|
||||
}
|
||||
}
|
||||
|
||||
return new JObject
|
||||
{
|
||||
["persona"] = persona,
|
||||
["voice"] = voice,
|
||||
["likes"] = likes,
|
||||
["dislikes"] = dislikes,
|
||||
["rules"] = rules,
|
||||
["extra"] = extra ?? "",
|
||||
};
|
||||
shelves["extra"] = extra ?? "";
|
||||
return shelves;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
foreach (JProperty prop in obj.Properties())
|
||||
string indent = new string(' ', Math.Max(0, depth) * 2);
|
||||
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 (!string.IsNullOrWhiteSpace(joined))
|
||||
if (item is JObject || item is JArray)
|
||||
{
|
||||
parts.Add($"{prop.Name}: {joined}");
|
||||
sb.AppendLine($"{indent}-");
|
||||
AppendTokenMarkdown(sb, item, depth + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
string s = item?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
sb.AppendLine($"{indent}- {s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (prop.Value?.Type == JTokenType.String)
|
||||
return;
|
||||
}
|
||||
if (token is JObject obj)
|
||||
{
|
||||
foreach (JProperty prop in obj.Properties())
|
||||
{
|
||||
string s = prop.Value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
if (prop.Value is JArray or JObject)
|
||||
{
|
||||
parts.Add($"{prop.Name}: {s}");
|
||||
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;
|
||||
}
|
||||
return string.Join("; ", parts);
|
||||
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)
|
||||
@@ -577,74 +967,41 @@ public sealed class AssistentConfig
|
||||
string id = SafeId(personaId) ?? "neutral";
|
||||
JObject parts = LoadIdentityParts(id);
|
||||
JObject persona = parts["persona"] as JObject ?? new JObject();
|
||||
JObject voice = parts["voice"] as JObject ?? new JObject();
|
||||
JObject likes = parts["likes"] as JObject ?? new JObject();
|
||||
JObject dislikes = parts["dislikes"] as JObject ?? new JObject();
|
||||
JObject rules = parts["rules"] as JObject ?? new JObject();
|
||||
string extra = parts["extra"]?.ToString() ?? "";
|
||||
|
||||
string title = persona["title"]?.ToString() ?? id;
|
||||
string tagline = persona["tagline"]?.ToString();
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine($"## Persona: {id} — {title}");
|
||||
|
||||
List<string> voiceBits = [];
|
||||
if (voice["verbosity"] != null)
|
||||
if (!string.IsNullOrWhiteSpace(tagline))
|
||||
{
|
||||
voiceBits.Add(voice["verbosity"].ToString());
|
||||
}
|
||||
if (voice["tone"] is JArray tones)
|
||||
{
|
||||
voiceBits.AddRange(tones.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
}
|
||||
if (voice["humor"] != null && voice["humor"].ToString() != "none")
|
||||
{
|
||||
voiceBits.Add($"humor:{voice["humor"]}");
|
||||
}
|
||||
if (voice["nsfw"] != null)
|
||||
{
|
||||
voiceBits.Add($"NSFW {voice["nsfw"]}");
|
||||
}
|
||||
if (voice["language"] != null)
|
||||
{
|
||||
voiceBits.Add(voice["language"].ToString());
|
||||
}
|
||||
if (voiceBits.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Voice: " + string.Join(", ", voiceBits));
|
||||
sb.AppendLine($"*{tagline}*");
|
||||
}
|
||||
|
||||
string prefers = FormatCategoryMap(likes);
|
||||
if (!string.IsNullOrWhiteSpace(prefers))
|
||||
foreach (JProperty prop in parts.Properties())
|
||||
{
|
||||
sb.AppendLine("Prefers: " + prefers);
|
||||
}
|
||||
string avoids = FormatCategoryMap(dislikes);
|
||||
if (!string.IsNullOrWhiteSpace(avoids))
|
||||
{
|
||||
sb.AppendLine("Avoids: " + avoids);
|
||||
}
|
||||
|
||||
List<string> ruleBits = [];
|
||||
if (rules["always"] is JArray always)
|
||||
{
|
||||
foreach (string s in always.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
if (prop.Name is "persona" or "extra")
|
||||
{
|
||||
ruleBits.Add("always " + s);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (rules["never"] is JArray never)
|
||||
{
|
||||
foreach (string s in never.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
if (prop.Value is not JObject shelf || !shelf.Properties().Any())
|
||||
{
|
||||
ruleBits.Add("never " + s);
|
||||
continue;
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"### {TitleCaseShelf(prop.Name)}");
|
||||
AppendTokenMarkdown(sb, shelf, 0);
|
||||
}
|
||||
if (ruleBits.Count > 0)
|
||||
|
||||
string controlsBlock = RenderControlsBlock(id);
|
||||
if (!string.IsNullOrWhiteSpace(controlsBlock))
|
||||
{
|
||||
sb.AppendLine("Rules: " + string.Join("; ", ruleBits));
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(controlsBlock);
|
||||
}
|
||||
|
||||
string extra = parts["extra"]?.ToString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(extra))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(extra.Trim());
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
@@ -791,6 +1148,8 @@ public sealed class AssistentConfig
|
||||
var skills = ListSkills(id);
|
||||
var personas = ListPersonaCatalog();
|
||||
JObject identity = LoadIdentityParts(id);
|
||||
JObject controlsSchema = LoadControlsSchema(id);
|
||||
JObject controlValues = LoadControlValues(id);
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
@@ -800,6 +1159,9 @@ public sealed class AssistentConfig
|
||||
["ui"] = ui,
|
||||
["model"] = model,
|
||||
["exact"] = exact,
|
||||
["controls"] = controlsSchema,
|
||||
["control_values"] = controlValues,
|
||||
["persona_source"] = PersonaSource(id),
|
||||
["packs"] = new JArray(packs.Select(p => new JObject
|
||||
{
|
||||
["id"] = p.id,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,10 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
||||
"memory_kind": null,
|
||||
"tag_query": null,
|
||||
"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"
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
|
||||
- `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.
|
||||
- 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_get"` / `"memory_search"` — hop: exact row or hybrid search.
|
||||
- `"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.
|
||||
- Pure Q&A with no change: omit the JSON patch.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "author_persona",
|
||||
"title": "Автор личности",
|
||||
"order": 80,
|
||||
"aliases": ["persona", "author", "clone_persona"],
|
||||
"prompt_file": "author_persona.md"
|
||||
}
|
||||
@@ -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
@@ -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|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": [
|
||||
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
|
||||
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
|
||||
@@ -32,7 +32,10 @@
|
||||
{ "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": "/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": {
|
||||
"write": "write_prompt",
|
||||
@@ -49,6 +52,10 @@
|
||||
"describe_ref": "describe_ref",
|
||||
"card": "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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"controls": {
|
||||
"preference_bias": 0.35
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Leonid",
|
||||
"tagline": "Технарь-пошляк, модели и промпты",
|
||||
"accent": "#f0883e"
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "normal",
|
||||
"tone": ["direct", "playful", "easily_aroused"],
|
||||
"humor": "lewd",
|
||||
"nsfw": "lean_in",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
@@ -16,21 +16,25 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
|
||||
```
|
||||
Config/
|
||||
_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/
|
||||
_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)
|
||||
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
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
**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
|
||||
|
||||
| Route | Role |
|
||||
| --- | --- |
|
||||
| `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) |
|
||||
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
|
||||
| `AssistentListPersonas` | Persona catalog |
|
||||
|
||||
@@ -36,7 +36,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.8.3";
|
||||
Version = "0.9.0";
|
||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
|
||||
}
|
||||
|
||||
@@ -76,7 +76,12 @@ public partial class SwarmAssistentExtension : Extension
|
||||
API.RegisterAPICall(AssistentGetMemory, false, PermUse);
|
||||
API.RegisterAPICall(AssistentLookupTags, 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)
|
||||
|
||||
@@ -55,9 +55,13 @@
|
||||
<div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div>
|
||||
</div>
|
||||
<div class="sa-header-right">
|
||||
<select id="sa_persona" class="sa-select" title="Характер / тон">
|
||||
<option value="neutral">Нейтральный</option>
|
||||
</select>
|
||||
<div class="sa-persona-wrap">
|
||||
<select id="sa_persona" class="sa-select" title="Характер / тон">
|
||||
<option value="neutral">Нейтральный</option>
|
||||
</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="Пакет промпта">
|
||||
<option value="write_prompt">Написать промпт</option>
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user