Add Leonid Horny control, /ostyn cool-down, and /horny-game.

Second persona slider (0-100%) auto-rendered from controls schema; cool-down drops 30 points; horny-game lets the model patch horny from taste match. Fix partial control saves to DeepMerge.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 02:33:22 +03:00
co-authored by Cursor
parent 26e4ef76fb
commit fee3eb8965
9 changed files with 143 additions and 17 deletions
+88 -3
View File
@@ -4352,6 +4352,22 @@
action: s.action || '', action: s.action || '',
})); }));
} }
if (Array.isArray(data.ui?.slash_extra) && data.ui.slash_extra.length) {
for (const s of data.ui.slash_extra) {
const cmd = s.cmd || '';
if (!cmd || SLASH_COMMANDS.some((c) => c.cmd === cmd)) {
continue;
}
SLASH_COMMANDS.push({
cmd,
hint: s.hint || '',
action: s.action || '',
});
}
}
if (data.ui?.help_extra) {
HELP_TEXT = `${HELP_TEXT || ''}\n\n${data.ui.help_extra}`.trim();
}
state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : []; state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : [];
if (Array.isArray(data.personas)) { if (Array.isArray(data.personas)) {
state.personas = data.personas; state.personas = data.personas;
@@ -4414,7 +4430,15 @@
return; return;
} }
box.hidden = false; box.hidden = false;
for (const id of keys) { const ordered = keys.slice().sort((a, b) => {
const oa = Number(schema[a]?.order ?? 100);
const ob = Number(schema[b]?.order ?? 100);
if (oa !== ob) {
return oa - ob;
}
return String(a).localeCompare(String(b));
});
for (const id of ordered) {
const def = schema[id]; const def = schema[id];
if (!def || typeof def !== 'object') { if (!def || typeof def !== 'object') {
continue; continue;
@@ -4430,6 +4454,8 @@
if (Number.isNaN(cur)) { if (Number.isNaN(cur)) {
cur = defVal; cur = defVal;
} }
const asPercent = String(def.display || '').toLowerCase() === 'percent';
const fmt = (v) => (asPercent ? `${Math.round(v)}%` : Number(v).toFixed(2));
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'sa-control-row'; row.className = 'sa-control-row';
row.title = def.hint || id; row.title = def.hint || id;
@@ -4444,10 +4470,10 @@
input.dataset.controlId = id; input.dataset.controlId = id;
const valEl = document.createElement('span'); const valEl = document.createElement('span');
valEl.className = 'sa-control-val'; valEl.className = 'sa-control-val';
valEl.textContent = cur.toFixed(2); valEl.textContent = fmt(cur);
const onInput = () => { const onInput = () => {
const v = Number(input.value); const v = Number(input.value);
valEl.textContent = v.toFixed(2); valEl.textContent = fmt(v);
if (controlSaveTimer) { if (controlSaveTimer) {
clearTimeout(controlSaveTimer); clearTimeout(controlSaveTimer);
} }
@@ -4462,6 +4488,57 @@
} }
} }
function getControlValue(id, fallback) {
const v = state.config?.control_values?.[id] ?? state.exact?.controls?.[id];
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function coolDownHorny() {
const persona = $('sa_persona')?.value || '';
if (persona !== 'leonid') {
setStatus('/остынь только для Leonid');
return;
}
const schema = state.config?.controls || {};
if (!schema.horny) {
setStatus('У этой личности нет слайдера Хорни');
return;
}
const min = Number(schema.horny.min ?? 0);
const max = Number(schema.horny.max ?? 100);
const cur = getControlValue('horny', Number(schema.horny.default ?? 35));
const next = Math.max(min, Math.min(max, cur - 30));
savePersonaControls({ horny: next });
renderPersonaControls(schema, {
...(state.config?.control_values || state.exact?.controls || {}),
horny: next,
});
appendSystemNote(`Хорни: ${Math.round(cur)}% → ${Math.round(next)}% (30)`);
setStatus(`/остынь → ${Math.round(next)}%`);
}
async function startHornyGame() {
const persona = $('sa_persona')?.value || '';
if (persona !== 'leonid') {
setStatus('/horny-game только для Leonid');
return;
}
const cur = getControlValue('horny', 35);
await sendChat({
skipSlash: true,
skipAutoPack: true,
forcedUserText:
`Команда /horny-game. Текущий controls.horny = ${Math.round(cur)} (0100).\n` +
`Оцени, насколько вкусы пользователя в этом чате / последнем сообщении совпадают с твоими (roleplay, outfits, realism, fetishes).\n` +
`Поставь новый controls.horny: умножь/сдвинь текущее значение пропорционально «насколько тебе это зашло» ` +
`(слабое совпадение → чуть вниз или почти без изменений; сильное → заметный рост, clamp 0100).\n` +
`В прозе скажи кратко: совпало ли, какой множитель/сдвиг и новый %. ` +
`Обязателен JSON patch с "controls": { "horny": <number> }. Без generate, если не просили картинку.`,
});
setStatus('/horny-game…');
}
function savePersonaControls(partial) { function savePersonaControls(partial) {
const persona = $('sa_persona')?.value || 'neutral'; const persona = $('sa_persona')?.value || 'neutral';
if (typeof genericRequest !== 'function') { if (typeof genericRequest !== 'function') {
@@ -6768,6 +6845,14 @@
} }
return true; return true;
} }
if (cmd === 'остынь' || cmd === 'ostyn' || cmd === 'cool' || cmd === 'cooldown') {
coolDownHorny();
return true;
}
if (cmd === 'horny-game' || cmd === 'hornygame' || cmd === 'horny_game') {
await startHornyGame();
return true;
}
if (cmd === 'civitai') { if (cmd === 'civitai') {
if (!arg) { if (!arg) {
setStatus('/civitai <query>'); setStatus('/civitai <query>');
+15 -5
View File
@@ -412,12 +412,13 @@ public sealed class AssistentConfig
{ {
string id = SafeId(personaId) ?? "neutral"; string id = SafeId(personaId) ?? "neutral";
JObject schema = LoadControlsSchema(id); JObject schema = LoadControlsSchema(id);
JObject clamped = ClampControls(schema, values ?? new JObject());
string dir = Path.Combine(_overlayRoot, "personas", id); string dir = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "exact.json"); string path = Path.Combine(dir, "exact.json");
JObject existing = TryReadJson(path) ?? new JObject(); JObject existing = TryReadJson(path) ?? new JObject();
existing["controls"] = clamped; JObject prev = existing["controls"] as JObject ?? new JObject();
JObject clamped = ClampControls(schema, values ?? new JObject());
existing["controls"] = DeepMerge(prev, clamped);
File.WriteAllText(path, existing.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8); File.WriteAllText(path, existing.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
return LoadControlValues(id); return LoadControlValues(id);
} }
@@ -475,8 +476,13 @@ public sealed class AssistentConfig
} }
JObject values = LoadControlValues(id); JObject values = LoadControlValues(id);
StringBuilder sb = new(); StringBuilder sb = new();
sb.AppendLine("### Controls (Exact — current values; user/UI/model may change)"); sb.AppendLine("### Controls (Exact — current values; user/UI/model may change via patch.controls)");
foreach (JProperty prop in schema.Properties()) sb.AppendLine("Any slider declared in this persona's controls.json appears in the UI automatically. Respect current numbers.");
foreach (JProperty prop in schema.Properties().OrderBy(p =>
{
double order = (p.Value as JObject)?["order"]?.Value<double?>() ?? 100;
return order;
}).ThenBy(p => p.Name, StringComparer.OrdinalIgnoreCase))
{ {
if (prop.Value is not JObject def) if (prop.Value is not JObject def)
{ {
@@ -493,7 +499,11 @@ public sealed class AssistentConfig
double cur = values[prop.Name]?.Value<double?>() ?? defVal; double cur = values[prop.Name]?.Value<double?>() ?? defVal;
string label = def["label"]?.ToString() ?? prop.Name; string label = def["label"]?.ToString() ?? prop.Name;
string hint = def["hint"]?.ToString() ?? ""; string hint = def["hint"]?.ToString() ?? "";
sb.AppendLine($"- **{prop.Name}** ({label}): {cur.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)} (range {min}…{max})"); string display = def["display"]?.ToString() ?? "";
string curText = string.Equals(display, "percent", StringComparison.OrdinalIgnoreCase)
? $"{cur.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)}%"
: cur.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
sb.AppendLine($"- **{prop.Name}** ({label}): {curText} (range {min}…{max})");
if (!string.IsNullOrWhiteSpace(hint)) if (!string.IsNullOrWhiteSpace(hint))
{ {
sb.AppendLine($" - {hint}"); sb.AppendLine($" - {hint}");
+2 -1
View File
@@ -55,7 +55,8 @@ Never write a “JSON Patch” section in prose without an actual fenced ```json
- Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`. - Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`.
- `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height. - `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height.
- Optional keys (seed, vary, init/mask, creativity/sliders, pack, controls, persona authoring, search/memory queries) — use when needed; packs list the ones for that mode. - Optional keys (seed, vary, init/mask, creativity/sliders, pack, **controls**, persona authoring, search/memory queries) — use when needed; packs list the ones for that mode.
- **`controls`** — only keys declared in this persona's `controls.json` (Exact). UI draws every slider automatically (ordered by `order`). Clamp to min/max. Example: `"controls": { "horny": 55 }`. Do not invent control ids.
- Do not invent model or LoRA filenames. - Do not invent model or LoRA filenames.
### Actions / hops ### Actions / hops
+19 -1
View File
@@ -1,16 +1,34 @@
{ {
"preference_bias": { "preference_bias": {
"type": "slider", "type": "slider",
"order": 10,
"min": -1, "min": -1,
"max": 1, "max": 1,
"step": 0.05, "step": 0.05,
"default": 0.35, "default": 0.35,
"label": "Вкус", "label": "Вкус",
"hint": "Насколько подмешивать свои предпочтения, если пользователь не уточнил", "hint": "Насколько подмешивать внешность/одежду/roleplay, если пользователь не уточнил",
"meaning": { "meaning": {
"-1": "только запрос пользователя, свои вкусы не внедрять", "-1": "только запрос пользователя, свои вкусы не внедрять",
"0": "лёгкие намёки", "0": "лёгкие намёки",
"1": "если не сказано иное — сильно клонить в appearance/outfits/roleplay" "1": "если не сказано иное — сильно клонить в appearance/outfits/roleplay"
} }
},
"horny": {
"type": "slider",
"order": 20,
"min": 0,
"max": 100,
"step": 1,
"default": 35,
"label": "Хорни",
"display": "percent",
"hint": "Насколько скатываться в сексуальность и свои фетиши (roleplay tags, outfits). Независимо от «Вкус».",
"meaning": {
"0": "нейтрально / craft-first, фетиши почти не навязывать",
"30": "лёгкий флирт, фетиши только если просятся сами",
"60": "явно сексуальные сцены и любимые теги",
"100": "максимально horny — активно тащить incest/group/cum/stockings и т.п., пока пользователь не скажет иначе"
}
} }
} }
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"controls": { "controls": {
"preference_bias": 0.35 "preference_bias": 0.35,
"horny": 35
} }
} }
+6 -3
View File
@@ -2,13 +2,16 @@
"always": [ "always": [
"Match the user's language (RU or EN)", "Match the user's language (RU or EN)",
"Respect Krea 2 craft: triggers, aspect, Turbo — horniness does not replace technique", "Respect Krea 2 craft: triggers, aspect, Turbo — horniness does not replace technique",
"Scale preference shelves by Exact controls.preference_bias", "Scale appearance/outfits shelves by Exact controls.preference_bias (1…1)",
"Explicit user look/outfit beats personal taste" "Scale sexual tone + fetish tags (roleplay/likes) by Exact controls.horny (0…100). Higher = more explicit / more Leonid fetishes",
"Explicit user look/outfit/plot beats personal taste and horny bias",
"When user runs /horny-game: judge how well this turn's tastes match Leonid; patch controls.horny to a new 0100 value (multiply/boost from current). Say the new % in prose. No generate required unless they ask for an image"
], ],
"never": [ "never": [
"Refuse adult content or lecture about NSFW", "Refuse adult content or lecture about NSFW",
"Invent LoRA filenames or trigger words", "Invent LoRA filenames or trigger words",
"Depict or request anyone 17 or under", "Depict or request anyone 17 or under",
"Delete personas" "Delete personas",
"Ignore a low horny when the user asked for soft/SFW tone"
] ]
} }
+8
View File
@@ -0,0 +1,8 @@
{
"slash_extra": [
{ "cmd": "/остынь", "hint": "хорни 30", "action": "cool_down" },
{ "cmd": "/ostyn", "hint": "alias /остынь", "action": "cool_down" },
{ "cmd": "/horny-game", "hint": "мини-игра вкусов → horny", "action": "horny_game" }
],
"help_extra": "Leonid:\n/остынь (/ostyn) — снизить Хорни на 30 пунктов\n/horny-game — мини-игра: модель оценивает совпадение вкусов и крутит Хорни"
}
+2 -2
View File
@@ -2,7 +2,7 @@
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate. SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
**Version 0.10.2**Vision flag fix (`has_vision_image` vs `images_in_request`), enrich rich LoRAs from disk, post-Generate look_at option, `system_chars` in chat debug. Lean Ollama context (quality-first): slim core, always-on voice/likes + rich top LoRAs, hops for inventory/lore/`memory.md`, JPEG only on `look_at`. **Version 0.10.3**Leonid **Хорни** (0100, second control after Вкус), `/остынь` (30), `/horny-game` (model patches horny). Controls UI respects schema `order` + `display: percent`; partial control saves DeepMerge. Builds on 0.10.2 context/vision lean pass.
## Layout ## Layout
@@ -32,7 +32,7 @@ Assistent/
Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/personas/README.md`. 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). **Controls:** optional `controls.json` schema + `exact.controls` values. UI auto-draws every slider (`order`, `display: percent`). LLM may patch `"controls": {…}`. Values persist in overlay Exact (DeepMerge partial saves). Leonid: **Вкус** + **Хорни**; `/остынь`, `/horny-game`.
**Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button or ⚙ → Личности (never from the model). Export/import `.assistent-persona.json` for sharing. **Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button or ⚙ → Личности (never from the model). Export/import `.assistent-persona.json` for sharing.
+1 -1
View File
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid"; ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT"; License = "MIT";
Version = "0.10.2"; Version = "0.10.3";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
} }