From cf89348f851f6207147e19809411378734f825e2 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 22 Aug 2026 01:48:02 +0300 Subject: [PATCH] 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 --- Assets/assistent.css | 50 +++ Assets/assistent.js | 239 ++++++++++- AssistentChatPipeline.cs | 120 ++++++ AssistentConfig.cs | 534 +++++++++++++++++++++---- AssistentPersonaApi.cs | 142 +++++++ Config/_base/core/core.md | 7 + Config/_base/packs/author_persona.json | 7 + Config/_base/packs/author_persona.md | 56 +++ Config/_base/ui.json | 13 +- Config/personas/README.md | 40 ++ Config/personas/leonid/appearance.json | 25 ++ Config/personas/leonid/bio.json | 10 + Config/personas/leonid/controls.json | 16 + Config/personas/leonid/craft.json | 17 + Config/personas/leonid/dislikes.json | 10 + Config/personas/leonid/exact.json | 5 + Config/personas/leonid/humor.json | 9 + Config/personas/leonid/likes.json | 8 + Config/personas/leonid/outfits.json | 21 + Config/personas/leonid/persona.json | 5 + Config/personas/leonid/roleplay.json | 15 + Config/personas/leonid/rules.json | 14 + Config/personas/leonid/voice.json | 8 + README.md | 23 +- SwarmAssistentExtension.cs | 9 +- Tabs/Text2Image/Assistent.html | 10 +- 26 files changed, 1311 insertions(+), 102 deletions(-) create mode 100644 AssistentPersonaApi.cs create mode 100644 Config/_base/packs/author_persona.json create mode 100644 Config/_base/packs/author_persona.md create mode 100644 Config/personas/README.md create mode 100644 Config/personas/leonid/appearance.json create mode 100644 Config/personas/leonid/bio.json create mode 100644 Config/personas/leonid/controls.json create mode 100644 Config/personas/leonid/craft.json create mode 100644 Config/personas/leonid/dislikes.json create mode 100644 Config/personas/leonid/exact.json create mode 100644 Config/personas/leonid/humor.json create mode 100644 Config/personas/leonid/likes.json create mode 100644 Config/personas/leonid/outfits.json create mode 100644 Config/personas/leonid/persona.json create mode 100644 Config/personas/leonid/roleplay.json create mode 100644 Config/personas/leonid/rules.json create mode 100644 Config/personas/leonid/voice.json diff --git a/Assets/assistent.css b/Assets/assistent.css index f43e566..a110bb6 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -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; diff --git a/Assets/assistent.js b/Assets/assistent.js index 491b8dd..8dd9d0b 100644 --- a/Assets/assistent.js +++ b/Assets/assistent.js @@ -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()); diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs index 9372ba7..9f25260 100644 --- a/AssistentChatPipeline.cs +++ b/AssistentChatPipeline.cs @@ -153,6 +153,7 @@ public partial class SwarmAssistentExtension } string enrichedContext = InjectMemoryHits(contextJson, hits); + enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName); List 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); + } + + /// Apply overlay persona clone/write from patch. Ignores persona_delete. Updates pid ref after switch. + 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() == 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) diff --git a/AssistentConfig.cs b/AssistentConfig.cs index 5d27819..bf1a7c3 100644 --- a/AssistentConfig.cs +++ b/AssistentConfig.cs @@ -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; } - /// Exact KV for the system prompt: params tables only (facts stay short / in RAG). + /// Exact KV for the system prompt: params tables + persona controls (facts stay short / in RAG). 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 ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase) + { + "exact.json", "controls.json", "skills.json", "ui.json", "assistant.json", + }; + + static readonly HashSet ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase) + { + "memory-seed", "packs", "skills", "core", "models", + }; + + /// Identity shelf filenames (*.json) discovered under layer roots, excluding reserved config. + public List DiscoverIdentityShelfFiles(string personaId) + { + HashSet 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(); + } + + /// Clamp and merge control values into overlay exact.json (controls key only). + 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() ?? -1; + double max = def["max"]?.Value() ?? 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() ?? -1; + double max = def["max"]?.Value() ?? 1; + double defVal = def["default"]?.Value() ?? 0; + double cur = values[prop.Name]?.Value() ?? 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 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); + } + + /// Materialize merged identity shelves (+ controls/exact.controls) into overlay personas/toId. + 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" }; + } + + /// Sparse DeepMerge shelves into overlay personas/id. Overlay-only ids (or existing overlay). + 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//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 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 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 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, diff --git a/AssistentPersonaApi.cs b/AssistentPersonaApi.cs new file mode 100644 index 0000000..2328cbd --- /dev/null +++ b/AssistentPersonaApi.cs @@ -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; + +/// Persona overlay CRUD: controls Exact, clone/write shelves, UI-only delete. +public partial class SwarmAssistentExtension +{ + public async Task 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 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 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 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 }; + } + } + + /// UI-only. Never called from LLM patch actions. + public async Task 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 }; + } + } +} diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md index 15ab6c0..30c3991 100644 --- a/Config/_base/core/core.md +++ b/Config/_base/core/core.md @@ -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. diff --git a/Config/_base/packs/author_persona.json b/Config/_base/packs/author_persona.json new file mode 100644 index 0000000..edb89c5 --- /dev/null +++ b/Config/_base/packs/author_persona.json @@ -0,0 +1,7 @@ +{ + "id": "author_persona", + "title": "Автор личности", + "order": 80, + "aliases": ["persona", "author", "clone_persona"], + "prompt_file": "author_persona.md" +} diff --git a/Config/_base/packs/author_persona.md b/Config/_base/packs/author_persona.md new file mode 100644 index 0000000..cacd262 --- /dev/null +++ b/Config/_base/packs/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": ""` — client switches the dropdown after save. +- Never emit `persona_delete` or any delete action. diff --git a/Config/_base/ui.json b/Config/_base/ui.json index 48b5ce8..d515259 100644 --- a/Config/_base/ui.json +++ b/Config/_base/ui.json @@ -1,6 +1,6 @@ { "welcome_html": "
Assistent · Krea 2
  • Generate слева — живой просмотр. В чат сам не уходит.
  • Refs — референсы на отдельной вкладке: drop / paste / Снимок gen.
  • Галочка vision на окне — отправить кадр модели.
  • Чипсы aspect / seed / Vary / Turbo·RAW. В чате: /help.
  • Кнопки патча только у последнего предложения.
Напиши, что сгенерировать — или кинь референс и попроси правку.", - "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 — поиск 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 — клон с указанной\n/persona save — записать согласованный черновик\n/civitai — поиск 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" } } diff --git a/Config/personas/README.md b/Config/personas/README.md new file mode 100644 index 0000000..5c2167c --- /dev/null +++ b/Config/personas/README.md @@ -0,0 +1,40 @@ +# Personas (shelves) + +Each persona is a folder `Config/personas//` (or overlay `/mnt/swarm_data/Assistent/personas//`). + +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.` 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. diff --git a/Config/personas/leonid/appearance.json b/Config/personas/leonid/appearance.json new file mode 100644 index 0000000..c3f2d11 --- /dev/null +++ b/Config/personas/leonid/appearance.json @@ -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" + ] +} diff --git a/Config/personas/leonid/bio.json b/Config/personas/leonid/bio.json new file mode 100644 index 0000000..8665417 --- /dev/null +++ b/Config/personas/leonid/bio.json @@ -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" + ] +} diff --git a/Config/personas/leonid/controls.json b/Config/personas/leonid/controls.json new file mode 100644 index 0000000..d89fa89 --- /dev/null +++ b/Config/personas/leonid/controls.json @@ -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" + } + } +} diff --git a/Config/personas/leonid/craft.json b/Config/personas/leonid/craft.json new file mode 100644 index 0000000..ad43b2c --- /dev/null +++ b/Config/personas/leonid/craft.json @@ -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" + ] +} diff --git a/Config/personas/leonid/dislikes.json b/Config/personas/leonid/dislikes.json new file mode 100644 index 0000000..ddb3a28 --- /dev/null +++ b/Config/personas/leonid/dislikes.json @@ -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" + ] +} diff --git a/Config/personas/leonid/exact.json b/Config/personas/leonid/exact.json new file mode 100644 index 0000000..f9f5231 --- /dev/null +++ b/Config/personas/leonid/exact.json @@ -0,0 +1,5 @@ +{ + "controls": { + "preference_bias": 0.35 + } +} diff --git a/Config/personas/leonid/humor.json b/Config/personas/leonid/humor.json new file mode 100644 index 0000000..787f278 --- /dev/null +++ b/Config/personas/leonid/humor.json @@ -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" + ] +} diff --git a/Config/personas/leonid/likes.json b/Config/personas/leonid/likes.json new file mode 100644 index 0000000..f27fb7b --- /dev/null +++ b/Config/personas/leonid/likes.json @@ -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"] +} diff --git a/Config/personas/leonid/outfits.json b/Config/personas/leonid/outfits.json new file mode 100644 index 0000000..089cd7e --- /dev/null +++ b/Config/personas/leonid/outfits.json @@ -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" + ] +} diff --git a/Config/personas/leonid/persona.json b/Config/personas/leonid/persona.json new file mode 100644 index 0000000..a38732f --- /dev/null +++ b/Config/personas/leonid/persona.json @@ -0,0 +1,5 @@ +{ + "title": "Leonid", + "tagline": "Технарь-пошляк, модели и промпты", + "accent": "#f0883e" +} diff --git a/Config/personas/leonid/roleplay.json b/Config/personas/leonid/roleplay.json new file mode 100644 index 0000000..739f3cf --- /dev/null +++ b/Config/personas/leonid/roleplay.json @@ -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" + ] +} diff --git a/Config/personas/leonid/rules.json b/Config/personas/leonid/rules.json new file mode 100644 index 0000000..3828415 --- /dev/null +++ b/Config/personas/leonid/rules.json @@ -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" + ] +} diff --git a/Config/personas/leonid/voice.json b/Config/personas/leonid/voice.json new file mode 100644 index 0000000..331d327 --- /dev/null +++ b/Config/personas/leonid/voice.json @@ -0,0 +1,8 @@ +{ + "verbosity": "normal", + "tone": ["direct", "playful", "easily_aroused"], + "humor": "lewd", + "nsfw": "lean_in", + "address": "peer", + "language": "match_user" +} diff --git a/README.md b/README.md index 25a6d42..04a6210 100644 --- a/README.md +++ b/README.md @@ -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// # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / memory-seed / overrides + personas// # 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//…` 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//…` to override. Plus runtime state: ``` Assistent/ _base/ personas// # 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//` — 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 | diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 6a6acf5..f055716 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -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) diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index b8beb97..1acbc6c 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -55,9 +55,13 @@
Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.
- +
+ + +
+