Bump Assistent to 0.7.0: Config personas, skills, and vector memory.
Move prompts into Config/_base and persona folders; seed model facts into SQLite via Ollama embed and retrieve as memory_hits each turn. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -556,6 +556,23 @@
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sa-skills-label {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.sa-skills-box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.sa-settings .sa-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sa-messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
+277
-48
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
|
||||
* v0.6.0: board tabs, Cards form+Civitai fetch, LoRA chips, taste on disk, reliability fixes.
|
||||
* v0.7.0: Config presets, persona folders, vector memory, chat|memory model roles.
|
||||
*/
|
||||
(function () {
|
||||
const LS_BASE = 'swarm_assistent_base_url';
|
||||
const LS_MODEL = 'swarm_assistent_model';
|
||||
const LS_EMBED = 'swarm_assistent_embed_model';
|
||||
const LS_PACK = 'swarm_assistent_pack';
|
||||
const LS_PERSONA = 'swarm_assistent_persona';
|
||||
const LS_VIEW = 'swarm_assistent_view';
|
||||
@@ -23,7 +24,7 @@
|
||||
const MAX_REF_SLOTS = 4;
|
||||
const CONTEXT_PROMPT_MAX = 2000;
|
||||
|
||||
const ASPECT_TABLE = {
|
||||
let ASPECT_TABLE = {
|
||||
'1:1': [1024, 1024],
|
||||
'4:3': [1184, 896],
|
||||
'3:2': [1248, 832],
|
||||
@@ -34,7 +35,7 @@
|
||||
'9:16': [768, 1376],
|
||||
};
|
||||
|
||||
const PACK_ALIASES = {
|
||||
let PACK_ALIASES = {
|
||||
write: 'write_prompt',
|
||||
write_prompt: 'write_prompt',
|
||||
critique: 'critique_image',
|
||||
@@ -52,7 +53,7 @@
|
||||
catalog_card: 'catalog_card',
|
||||
};
|
||||
|
||||
const WELCOME_HTML = `
|
||||
let WELCOME_HTML = `
|
||||
<div class="sa-welcome-title">Assistent · Krea 2</div>
|
||||
<ul>
|
||||
<li><strong>Generate</strong> слева — живой просмотр. В чат сам не уходит.</li>
|
||||
@@ -63,7 +64,7 @@
|
||||
</ul>
|
||||
Напиши, что сгенерировать — или кинь референс и попроси правку.`;
|
||||
|
||||
const HELP_TEXT = `Slash-команды (без LLM):
|
||||
let HELP_TEXT = `Slash-команды (без LLM):
|
||||
/help — этот список
|
||||
/gen — Generate сейчас
|
||||
/look generate|refN — прикрепить окно к vision
|
||||
@@ -78,7 +79,7 @@
|
||||
|
||||
Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.`;
|
||||
|
||||
const SLASH_COMMANDS = [
|
||||
let SLASH_COMMANDS = [
|
||||
{ cmd: '/help', hint: 'список команд' },
|
||||
{ cmd: '/gen', hint: 'Generate сейчас' },
|
||||
{ cmd: '/look ', hint: 'generate|refN' },
|
||||
@@ -97,6 +98,10 @@
|
||||
const state = {
|
||||
history: [],
|
||||
packsLoaded: false,
|
||||
config: null,
|
||||
enabledSkills: [],
|
||||
kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } },
|
||||
preferredEmbed: null,
|
||||
busy: false,
|
||||
generating: false,
|
||||
chatEpoch: 0,
|
||||
@@ -1138,11 +1143,23 @@
|
||||
|
||||
function onPersonaChanged() {
|
||||
const id = $('sa_persona')?.value || 'neutral';
|
||||
const info = (state.personas || []).find((p) => p.id === id);
|
||||
const title = info?.title || id;
|
||||
saveSettings();
|
||||
appendSystemNote(`Тон → ${title}`);
|
||||
state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
|
||||
loadConfig(id, (data) => {
|
||||
const title = data?.personas?.find((p) => p.id === id)?.title
|
||||
|| (state.personas || []).find((p) => p.id === id)?.title
|
||||
|| id;
|
||||
if (data?.personas) {
|
||||
state.personas = data.personas;
|
||||
}
|
||||
appendSystemNote(`Тон → ${title}`);
|
||||
state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`;
|
||||
if (data?.assistant?.default_pack && $('sa_pack') && !state.packUserTouched) {
|
||||
const packId = data.assistant.default_pack;
|
||||
if ([...($('sa_pack').options || [])].some((o) => o.value === packId)) {
|
||||
$('sa_pack').value = packId;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function countPromptImages() {
|
||||
@@ -2765,6 +2782,10 @@
|
||||
if (model) {
|
||||
state.preferredModel = model;
|
||||
}
|
||||
const embed = localStorage.getItem(LS_EMBED);
|
||||
if (embed) {
|
||||
state.preferredEmbed = embed;
|
||||
}
|
||||
if (paneW) {
|
||||
document.documentElement.style.setProperty('--sa-image-width', paneW);
|
||||
}
|
||||
@@ -2780,6 +2801,7 @@
|
||||
function saveSettings() {
|
||||
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
|
||||
localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
|
||||
localStorage.setItem(LS_EMBED, $('sa_embed_model')?.value || state.preferredEmbed || '');
|
||||
localStorage.setItem(LS_PACK, $('sa_pack')?.value || 'write_prompt');
|
||||
localStorage.setItem(LS_PERSONA, $('sa_persona')?.value || 'neutral');
|
||||
localStorage.setItem(LS_VIEW, state.view || 'chat');
|
||||
@@ -2788,6 +2810,193 @@
|
||||
localStorage.setItem(LS_AUTO_GENERATE, $('sa_auto_generate')?.checked ? '1' : '0');
|
||||
localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
|
||||
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
|
||||
persistServerSettings();
|
||||
}
|
||||
|
||||
function persistServerSettings() {
|
||||
if (typeof genericRequest !== 'function') {
|
||||
return;
|
||||
}
|
||||
const skills = {};
|
||||
document.querySelectorAll('#sa_skills_box input[data-skill]')?.forEach((el) => {
|
||||
skills[el.getAttribute('data-skill')] = !!el.checked;
|
||||
});
|
||||
const persona = $('sa_persona')?.value || 'neutral';
|
||||
const settings = {
|
||||
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
|
||||
base_url: $('sa_base_url')?.value || '',
|
||||
[persona]: { skills },
|
||||
};
|
||||
genericRequest('AssistentSaveSettings', { settings }, () => {}, 0, () => {});
|
||||
}
|
||||
|
||||
function applyConfigPayload(data, { applyDefaults = false } = {}) {
|
||||
if (!data || data.error) {
|
||||
return;
|
||||
}
|
||||
state.config = data;
|
||||
if (data.model?.aspect_table && typeof data.model.aspect_table === 'object') {
|
||||
const next = {};
|
||||
for (const [k, v] of Object.entries(data.model.aspect_table)) {
|
||||
if (Array.isArray(v) && v.length >= 2) {
|
||||
next[k] = [Number(v[0]), Number(v[1])];
|
||||
}
|
||||
}
|
||||
if (Object.keys(next).length) {
|
||||
ASPECT_TABLE = next;
|
||||
}
|
||||
}
|
||||
if (data.model?.profiles) {
|
||||
state.kreaProfiles = data.model.profiles;
|
||||
}
|
||||
if (data.ui?.pack_aliases) {
|
||||
PACK_ALIASES = { ...PACK_ALIASES, ...data.ui.pack_aliases };
|
||||
}
|
||||
if (data.ui?.welcome_html) {
|
||||
WELCOME_HTML = data.ui.welcome_html;
|
||||
}
|
||||
if (data.ui?.help_text) {
|
||||
HELP_TEXT = data.ui.help_text;
|
||||
}
|
||||
if (Array.isArray(data.ui?.slash) && data.ui.slash.length) {
|
||||
SLASH_COMMANDS = data.ui.slash.map((s) => ({
|
||||
cmd: s.cmd || '',
|
||||
hint: s.hint || '',
|
||||
action: s.action || '',
|
||||
}));
|
||||
}
|
||||
state.enabledSkills = Array.isArray(data.enabled_skills) ? data.enabled_skills.slice() : [];
|
||||
if (Array.isArray(data.personas)) {
|
||||
state.personas = data.personas;
|
||||
}
|
||||
renderPersonaOptions(data.personas || [], data.persona || data.default_persona);
|
||||
renderPackOptions(data.packs || [], applyDefaults ? data.assistant?.default_pack : null);
|
||||
renderChips(data.ui?.chips || []);
|
||||
renderSkillChecks(data.skills || [], state.enabledSkills);
|
||||
if (applyDefaults && data.assistant?.default_pack && $('sa_pack') && !localStorage.getItem(LS_PACK)) {
|
||||
$('sa_pack').value = data.assistant.default_pack;
|
||||
}
|
||||
if (data.assistant?.embed_model && !state.preferredEmbed) {
|
||||
state.preferredEmbed = data.assistant.embed_model;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPersonaOptions(personas, selected) {
|
||||
const sel = $('sa_persona');
|
||||
if (!sel) {
|
||||
return;
|
||||
}
|
||||
const cur = selected || sel.value || localStorage.getItem(LS_PERSONA) || 'neutral';
|
||||
sel.innerHTML = '';
|
||||
for (const p of personas) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.title || p.id;
|
||||
if (p.accent) {
|
||||
opt.dataset.accent = p.accent;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if ([...sel.options].some((o) => o.value === cur)) {
|
||||
sel.value = cur;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPackOptions(packs, preferred) {
|
||||
const sel = $('sa_pack');
|
||||
if (!sel) {
|
||||
return;
|
||||
}
|
||||
const cur = preferred || sel.value || localStorage.getItem(LS_PACK) || 'write_prompt';
|
||||
sel.innerHTML = '';
|
||||
const list = (packs || []).slice().sort((a, b) => (a.order || 100) - (b.order || 100));
|
||||
for (const p of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.title || p.id;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if ([...sel.options].some((o) => o.value === cur)) {
|
||||
sel.value = cur;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChips(chips) {
|
||||
const box = $('sa_chips');
|
||||
if (!box || !Array.isArray(chips) || !chips.length) {
|
||||
return;
|
||||
}
|
||||
box.innerHTML = '';
|
||||
for (const c of chips) {
|
||||
if (c.sep) {
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'sa-chip-sep';
|
||||
sep.setAttribute('aria-hidden', 'true');
|
||||
box.appendChild(sep);
|
||||
continue;
|
||||
}
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sa-chip';
|
||||
btn.textContent = c.label || c.value || '';
|
||||
if (c.title) {
|
||||
btn.title = c.title;
|
||||
}
|
||||
const action = c.action || '';
|
||||
const value = c.value ?? '';
|
||||
if (action === 'aspect') {
|
||||
btn.setAttribute('data-aspect', value);
|
||||
} else if (action === 'seed') {
|
||||
btn.setAttribute('data-seed', value);
|
||||
} else if (action === 'vary') {
|
||||
btn.setAttribute('data-vary', value || '1');
|
||||
} else if (action === 'krea_profile') {
|
||||
btn.setAttribute('data-krea-profile', value);
|
||||
}
|
||||
box.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSkillChecks(skills, enabled) {
|
||||
const box = $('sa_skills_box');
|
||||
if (!box) {
|
||||
return;
|
||||
}
|
||||
const on = new Set(enabled || []);
|
||||
box.innerHTML = '';
|
||||
for (const s of skills || []) {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'sa-check';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.setAttribute('data-skill', s.id);
|
||||
input.checked = on.has(s.id) || (!enabled?.length && !!s.default);
|
||||
input.addEventListener('change', () => {
|
||||
state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
|
||||
saveSettings();
|
||||
});
|
||||
label.appendChild(input);
|
||||
label.appendChild(document.createTextNode(` ${s.title || s.id}`));
|
||||
box.appendChild(label);
|
||||
}
|
||||
state.enabledSkills = [...document.querySelectorAll('#sa_skills_box input[data-skill]:checked')].map((el) => el.getAttribute('data-skill'));
|
||||
}
|
||||
|
||||
function loadConfig(persona, done) {
|
||||
if (typeof genericRequest !== 'function') {
|
||||
done?.(null);
|
||||
return;
|
||||
}
|
||||
genericRequest(
|
||||
'AssistentGetConfig',
|
||||
{ persona: persona || $('sa_persona')?.value || 'neutral' },
|
||||
(data) => {
|
||||
applyConfigPayload(data, { applyDefaults: true });
|
||||
done?.(data);
|
||||
},
|
||||
0,
|
||||
() => done?.(null),
|
||||
);
|
||||
}
|
||||
|
||||
function setModelOptions(models, { error } = {}) {
|
||||
@@ -2825,6 +3034,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
function setEmbedModelOptions(models) {
|
||||
const sel = $('sa_embed_model');
|
||||
if (!sel) {
|
||||
return;
|
||||
}
|
||||
const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
|
||||
sel.innerHTML = '';
|
||||
if (!names.length) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = state.preferredEmbed || 'nomic-embed-text';
|
||||
opt.textContent = opt.value + ' (ожидается pull)';
|
||||
sel.appendChild(opt);
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = name;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
const prefer = state.preferredEmbed || localStorage.getItem(LS_EMBED) || state.config?.assistant?.embed_model;
|
||||
if (prefer && names.includes(prefer)) {
|
||||
sel.value = prefer;
|
||||
} else if (prefer && !names.includes(prefer)) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = prefer;
|
||||
opt.textContent = prefer;
|
||||
sel.appendChild(opt);
|
||||
sel.value = prefer;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshModels() {
|
||||
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
|
||||
setStatus('Loading models…');
|
||||
@@ -2838,12 +3079,14 @@
|
||||
{ baseUrl },
|
||||
(data) => {
|
||||
const models = data.models || [];
|
||||
const memoryModels = data.memory_models || [];
|
||||
setModelOptions(models);
|
||||
setEmbedModelOptions(memoryModels);
|
||||
const prefer = state.preferredModel || localStorage.getItem(LS_MODEL);
|
||||
if (prefer && models.includes(prefer) && $('sa_model')) {
|
||||
$('sa_model').value = prefer;
|
||||
}
|
||||
setStatus(models.length ? `${models.length} models` : 'No Ollama models (gpu-rent: ollama pull)');
|
||||
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
|
||||
saveSettings();
|
||||
},
|
||||
0,
|
||||
@@ -2928,36 +3171,11 @@
|
||||
}
|
||||
|
||||
function refreshPersonas() {
|
||||
if (typeof genericRequest !== 'function') {
|
||||
return;
|
||||
}
|
||||
genericRequest(
|
||||
'AssistentListPersonas',
|
||||
{},
|
||||
(data) => {
|
||||
const list = data.personas || [];
|
||||
state.personas = list;
|
||||
const sel = $('sa_persona');
|
||||
if (!sel) {
|
||||
return;
|
||||
}
|
||||
const prefer = localStorage.getItem(LS_PERSONA) || data.default || 'neutral';
|
||||
sel.innerHTML = '';
|
||||
for (const p of list) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.id;
|
||||
opt.textContent = p.title || p.id;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if ([...sel.options].some((o) => o.value === prefer)) {
|
||||
sel.value = prefer;
|
||||
} else if (data.default) {
|
||||
sel.value = data.default;
|
||||
}
|
||||
},
|
||||
0,
|
||||
(err) => console.warn('Assistent personas', err),
|
||||
);
|
||||
loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => {
|
||||
if (data?.personas) {
|
||||
state.personas = data.personas;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function prefetchCard(kind, name) {
|
||||
@@ -4132,6 +4350,8 @@
|
||||
includeBase: true,
|
||||
messages,
|
||||
context_json: JSON.stringify(context),
|
||||
skills: state.enabledSkills || [],
|
||||
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
|
||||
raw: {
|
||||
messages,
|
||||
context_json: JSON.stringify(context),
|
||||
@@ -4139,6 +4359,8 @@
|
||||
persona,
|
||||
base_url: baseUrl,
|
||||
model,
|
||||
skills: state.enabledSkills || [],
|
||||
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4431,11 +4653,12 @@
|
||||
}
|
||||
restoreHistory();
|
||||
maybeWelcome();
|
||||
refreshModels();
|
||||
refreshPersonas();
|
||||
refreshInventory(() => {
|
||||
renderCardsList();
|
||||
renderLoraChips();
|
||||
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
|
||||
refreshModels();
|
||||
refreshInventory(() => {
|
||||
renderCardsList();
|
||||
renderLoraChips();
|
||||
});
|
||||
});
|
||||
wireDropZone();
|
||||
wireSplitter();
|
||||
@@ -4537,6 +4760,10 @@
|
||||
});
|
||||
$('sa_base_url')?.addEventListener('change', saveSettings);
|
||||
$('sa_model')?.addEventListener('change', saveSettings);
|
||||
$('sa_embed_model')?.addEventListener('change', () => {
|
||||
state.preferredEmbed = $('sa_embed_model')?.value || '';
|
||||
saveSettings();
|
||||
});
|
||||
$('sa_pack')?.addEventListener('change', () => {
|
||||
state.packUserTouched = true;
|
||||
saveSettings();
|
||||
@@ -4559,9 +4786,11 @@
|
||||
} else if (vary) {
|
||||
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary');
|
||||
} else if (profile === 'turbo') {
|
||||
await applyQuickPatch({ steps: 8, cfg: 1, sigma_shift: 1.15, actions: ['generate'] }, 'Turbo 8/1');
|
||||
const p = state.kreaProfiles?.turbo || { steps: 8, cfg: 1, sigma_shift: 1.15 };
|
||||
await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo');
|
||||
} else if (profile === 'raw') {
|
||||
await applyQuickPatch({ steps: 28, cfg: 4.5, actions: ['generate'] }, 'RAW 28/4.5');
|
||||
const p = state.kreaProfiles?.raw || { steps: 28, cfg: 4.5 };
|
||||
await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, actions: ['generate'] }, 'RAW');
|
||||
}
|
||||
renderLoraChips();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,752 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using FreneticUtilities.FreneticExtensions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Loads Config/_base + personas/<id> sparse presets with disk overlay merge.</summary>
|
||||
public sealed class AssistentConfig
|
||||
{
|
||||
readonly string _bundledRoot;
|
||||
readonly string _overlayRoot;
|
||||
readonly object _lock = new();
|
||||
|
||||
public AssistentConfig(string extensionFilePath, string dataRoot)
|
||||
{
|
||||
_bundledRoot = Path.Combine(extensionFilePath ?? "", "Config");
|
||||
_overlayRoot = Path.Combine(dataRoot ?? "", "Assistent");
|
||||
}
|
||||
|
||||
public string BundledRoot => _bundledRoot;
|
||||
public string OverlayRoot => _overlayRoot;
|
||||
|
||||
public static string SafeId(string id)
|
||||
{
|
||||
string s = (id ?? "").Replace('\\', '/').AfterLast('/').Replace("..", "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(s) || !Regex.IsMatch(s, @"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static string SafeRel(string relative)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string norm = relative.Replace('\\', '/').TrimStart('/');
|
||||
if (norm.Contains("..", StringComparison.Ordinal) || Path.IsPathRooted(relative))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return norm.Replace('/', Path.DirectorySeparatorChar);
|
||||
}
|
||||
|
||||
public string ResolveUnder(string root, string relative)
|
||||
{
|
||||
string rel = SafeRel(relative);
|
||||
if (rel is null || string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string full = Path.GetFullPath(Path.Combine(root, rel));
|
||||
string rootFull = Path.GetFullPath(root);
|
||||
if (!full.StartsWith(rootFull.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(full, rootFull, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
public JObject DeepMerge(JObject bottom, JObject top)
|
||||
{
|
||||
if (bottom is null)
|
||||
{
|
||||
return top is null ? new JObject() : (JObject)top.DeepClone();
|
||||
}
|
||||
if (top is null)
|
||||
{
|
||||
return (JObject)bottom.DeepClone();
|
||||
}
|
||||
JObject result = (JObject)bottom.DeepClone();
|
||||
foreach (JProperty prop in top.Properties())
|
||||
{
|
||||
if (prop.Value is JObject topObj && result[prop.Name] is JObject botObj)
|
||||
{
|
||||
result[prop.Name] = DeepMerge(botObj, topObj);
|
||||
}
|
||||
else if (prop.Value is JArray || prop.Value is null || prop.Value.Type == JTokenType.Null)
|
||||
{
|
||||
// Arrays replace entirely when the top file provides them.
|
||||
if (prop.Value is not null && prop.Value.Type != JTokenType.Null)
|
||||
{
|
||||
result[prop.Name] = prop.Value.DeepClone();
|
||||
}
|
||||
}
|
||||
else if (prop.Value.Type == JTokenType.String && string.IsNullOrWhiteSpace(prop.Value.ToString()))
|
||||
{
|
||||
// Empty string does not clobber (personas.json empty prompt rule).
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
result[prop.Name] = prop.Value.DeepClone();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public JObject TryReadJson(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentConfig json {path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public JArray TryReadJsonArray(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
return JArray.Parse(File.ReadAllText(path, Encoding.UTF8));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentConfig json-array {path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public string TryReadText(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
string text = File.ReadAllText(path, Encoding.UTF8);
|
||||
return string.IsNullOrWhiteSpace(text) ? null : text;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentConfig text {path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Merge layered copies of the same relative path: bundled base → persona chain → disk base → disk persona.</summary>
|
||||
public JObject MergeJsonLayers(string relative, IEnumerable<string> roots)
|
||||
{
|
||||
JObject acc = null;
|
||||
foreach (string root in roots)
|
||||
{
|
||||
string path = ResolveUnder(root, relative);
|
||||
JObject next = TryReadJson(path);
|
||||
if (next is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
acc = DeepMerge(acc, next);
|
||||
}
|
||||
return acc ?? new JObject();
|
||||
}
|
||||
|
||||
public string MergeTextLayers(string relative, IEnumerable<string> roots)
|
||||
{
|
||||
string last = null;
|
||||
foreach (string root in roots)
|
||||
{
|
||||
string path = ResolveUnder(root, relative);
|
||||
string text = TryReadText(path);
|
||||
if (text is not null)
|
||||
{
|
||||
last = text;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
List<string> PersonaExtendsChain(string personaId)
|
||||
{
|
||||
List<string> chain = [];
|
||||
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||
string cur = SafeId(personaId) ?? "neutral";
|
||||
for (int i = 0; i < 8 && !string.IsNullOrWhiteSpace(cur); i++)
|
||||
{
|
||||
if (!seen.Add(cur))
|
||||
{
|
||||
break;
|
||||
}
|
||||
chain.Insert(0, cur);
|
||||
JObject meta = TryReadJson(ResolveUnder(Path.Combine(_bundledRoot, "personas", cur), "persona.json"))
|
||||
?? TryReadJson(ResolveUnder(Path.Combine(_overlayRoot, "personas", cur), "persona.json"));
|
||||
string parent = SafeId(meta?["extends"]?.ToString());
|
||||
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, cur, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
public IEnumerable<string> LayerRoots(string personaId)
|
||||
{
|
||||
yield return Path.Combine(_bundledRoot, "_base");
|
||||
foreach (string id in PersonaExtendsChain(personaId))
|
||||
{
|
||||
yield return Path.Combine(_bundledRoot, "personas", id);
|
||||
}
|
||||
yield return Path.Combine(_overlayRoot, "_base");
|
||||
foreach (string id in PersonaExtendsChain(personaId))
|
||||
{
|
||||
yield return Path.Combine(_overlayRoot, "personas", id);
|
||||
}
|
||||
}
|
||||
|
||||
public List<(string id, string title, string accent, string source)> ListPersonaCatalog()
|
||||
{
|
||||
Dictionary<string, (string title, string accent, string source)> byId = new(StringComparer.OrdinalIgnoreCase);
|
||||
void Scan(string root, string source)
|
||||
{
|
||||
string dir = Path.Combine(root, "personas");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (string folder in Directory.GetDirectories(dir))
|
||||
{
|
||||
string id = SafeId(Path.GetFileName(folder));
|
||||
if (id is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
JObject meta = TryReadJson(Path.Combine(folder, "persona.json"));
|
||||
if (meta is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (meta["enabled"]?.Value<bool?>() == false)
|
||||
{
|
||||
byId.Remove(id);
|
||||
continue;
|
||||
}
|
||||
byId[id] = (
|
||||
meta["title"]?.ToString() ?? id,
|
||||
meta["accent"]?.ToString() ?? "#8b949e",
|
||||
source
|
||||
);
|
||||
}
|
||||
}
|
||||
Scan(_bundledRoot, "bundled");
|
||||
Scan(_overlayRoot, "overlay");
|
||||
|
||||
// Legacy personas.json titles
|
||||
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
||||
JObject legacy = TryReadJson(overlayJson);
|
||||
if (legacy?["personas"] is JArray arr)
|
||||
{
|
||||
foreach (JToken t in arr)
|
||||
{
|
||||
if (t is not JObject po)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string id = SafeId(po["id"]?.ToString());
|
||||
if (id is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (byId.TryGetValue(id, out var cur))
|
||||
{
|
||||
byId[id] = (po["title"]?.ToString() ?? cur.title, cur.accent, "overlay+bundled");
|
||||
}
|
||||
else
|
||||
{
|
||||
byId[id] = (po["title"]?.ToString() ?? id, "#8b949e", "legacy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return byId.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kv => (kv.Key, kv.Value.title, kv.Value.accent, kv.Value.source))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string DefaultPersonaId()
|
||||
{
|
||||
JObject assistant = MergeJsonLayers("assistant.json", LayerRoots("neutral"));
|
||||
string def = SafeId(assistant["default_persona"]?.ToString()) ?? "neutral";
|
||||
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
||||
JObject legacy = TryReadJson(overlayJson);
|
||||
string fromLegacy = SafeId(legacy?["default"]?.ToString());
|
||||
if (fromLegacy is not null)
|
||||
{
|
||||
def = fromLegacy;
|
||||
}
|
||||
var catalog = ListPersonaCatalog();
|
||||
if (catalog.All(p => !string.Equals(p.id, def, StringComparison.OrdinalIgnoreCase)) && catalog.Count > 0)
|
||||
{
|
||||
def = catalog[0].id;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
public JObject LoadAssistant(string personaId) => MergeJsonLayers("assistant.json", LayerRoots(personaId));
|
||||
|
||||
public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId));
|
||||
|
||||
public JObject LoadModelProfile(string personaId)
|
||||
{
|
||||
JObject assistant = LoadAssistant(personaId);
|
||||
string arch = assistant["gate"]?["architecture"]?.ToString() ?? "krea2";
|
||||
string safe = SafeId(arch) ?? "krea2";
|
||||
return MergeJsonLayers(Path.Combine("models", $"{safe}.json"), LayerRoots(personaId));
|
||||
}
|
||||
|
||||
public string LoadCorePrompt(string personaId)
|
||||
{
|
||||
JObject meta = MergeJsonLayers(Path.Combine("core", "core.json"), LayerRoots(personaId));
|
||||
string file = meta["prompt_file"]?.ToString() ?? "core.md";
|
||||
return MergeTextLayers(Path.Combine("core", file), LayerRoots(personaId)) ?? "";
|
||||
}
|
||||
|
||||
public List<(string id, string title, int order, string[] aliases, bool enabled)> ListPacks(string personaId)
|
||||
{
|
||||
Dictionary<string, (string title, int order, string[] aliases, bool enabled)> byId = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string root in LayerRoots(personaId))
|
||||
{
|
||||
string dir = Path.Combine(root, "packs");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (string file in Directory.GetFiles(dir, "*.json"))
|
||||
{
|
||||
JObject meta = TryReadJson(file);
|
||||
string id = SafeId(meta?["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file));
|
||||
if (id is null || meta is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
bool enabled = meta["enabled"]?.Value<bool?>() != false;
|
||||
string[] aliases = (meta["aliases"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).ToArray() ?? [];
|
||||
byId[id] = (
|
||||
meta["title"]?.ToString() ?? id,
|
||||
meta["order"]?.Value<int?>() ?? 100,
|
||||
aliases,
|
||||
enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
return byId.Where(kv => kv.Value.enabled)
|
||||
.OrderBy(kv => kv.Value.order).ThenBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kv => (kv.Key, kv.Value.title, kv.Value.order, kv.Value.aliases, kv.Value.enabled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string LoadPackPrompt(string personaId, string packId)
|
||||
{
|
||||
string id = SafeId(packId);
|
||||
if (id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
JObject meta = MergeJsonLayers(Path.Combine("packs", $"{id}.json"), LayerRoots(personaId));
|
||||
if (meta["enabled"]?.Value<bool?>() == false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string file = meta["prompt_file"]?.ToString() ?? $"{id}.md";
|
||||
return MergeTextLayers(Path.Combine("packs", file), LayerRoots(personaId));
|
||||
}
|
||||
|
||||
public List<(string id, string title, bool defaultOn, bool enabled)> ListSkills(string personaId)
|
||||
{
|
||||
Dictionary<string, (string title, bool defaultOn, bool enabled)> byId = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string root in LayerRoots(personaId))
|
||||
{
|
||||
string dir = Path.Combine(root, "skills");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (string file in Directory.GetFiles(dir, "*.json"))
|
||||
{
|
||||
JObject meta = TryReadJson(file);
|
||||
string id = SafeId(meta?["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file));
|
||||
if (id is null || meta is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
byId[id] = (
|
||||
meta["title"]?.ToString() ?? id,
|
||||
meta["default"]?.Value<bool?>() ?? false,
|
||||
meta["enabled"]?.Value<bool?>() != false
|
||||
);
|
||||
}
|
||||
}
|
||||
JObject skillsOverride = MergeJsonLayers("skills.json", LayerRoots(personaId));
|
||||
foreach (JProperty prop in skillsOverride.Properties())
|
||||
{
|
||||
string id = SafeId(prop.Name);
|
||||
if (id is null || !byId.ContainsKey(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (prop.Value.Type == JTokenType.Boolean)
|
||||
{
|
||||
var cur = byId[id];
|
||||
byId[id] = (cur.title, prop.Value.Value<bool>(), cur.enabled);
|
||||
}
|
||||
}
|
||||
return byId.Where(kv => kv.Value.enabled)
|
||||
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(kv => (kv.Key, kv.Value.title, kv.Value.defaultOn, kv.Value.enabled))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string LoadSkillPrompt(string personaId, string skillId)
|
||||
{
|
||||
string id = SafeId(skillId);
|
||||
if (id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
JObject meta = MergeJsonLayers(Path.Combine("skills", $"{id}.json"), LayerRoots(personaId));
|
||||
if (meta["enabled"]?.Value<bool?>() == false)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string file = meta["prompt_file"]?.ToString() ?? $"{id}.md";
|
||||
return MergeTextLayers(Path.Combine("skills", file), LayerRoots(personaId));
|
||||
}
|
||||
|
||||
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 extra = MergeTextLayers("extra.md", roots);
|
||||
|
||||
// Legacy personas.json prompt → extra overlay
|
||||
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
||||
JObject legacy = TryReadJson(overlayJson);
|
||||
if (legacy?["personas"] is JArray arr)
|
||||
{
|
||||
string id = SafeId(personaId) ?? "neutral";
|
||||
foreach (JToken t in arr)
|
||||
{
|
||||
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string title = po["title"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
persona["title"] = title;
|
||||
}
|
||||
string prompt = po["prompt"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(prompt))
|
||||
{
|
||||
extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new JObject
|
||||
{
|
||||
["persona"] = persona,
|
||||
["voice"] = voice,
|
||||
["likes"] = likes,
|
||||
["dislikes"] = dislikes,
|
||||
["rules"] = rules,
|
||||
["extra"] = extra ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
static string FormatCategoryMap(JObject obj)
|
||||
{
|
||||
if (obj is null || !obj.Properties().Any())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
List<string> parts = [];
|
||||
foreach (JProperty prop in obj.Properties())
|
||||
{
|
||||
if (prop.Value is JArray arr)
|
||||
{
|
||||
string joined = string.Join(", ", arr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
if (!string.IsNullOrWhiteSpace(joined))
|
||||
{
|
||||
parts.Add($"{prop.Name}: {joined}");
|
||||
}
|
||||
}
|
||||
else if (prop.Value?.Type == JTokenType.String)
|
||||
{
|
||||
string s = prop.Value.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
parts.Add($"{prop.Name}: {s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
public string RenderIdentityBlock(string personaId)
|
||||
{
|
||||
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;
|
||||
StringBuilder sb = new();
|
||||
sb.AppendLine($"## Persona: {id} — {title}");
|
||||
|
||||
List<string> voiceBits = [];
|
||||
if (voice["verbosity"] != null)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
string prefers = FormatCategoryMap(likes);
|
||||
if (!string.IsNullOrWhiteSpace(prefers))
|
||||
{
|
||||
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)))
|
||||
{
|
||||
ruleBits.Add("always " + s);
|
||||
}
|
||||
}
|
||||
if (rules["never"] is JArray never)
|
||||
{
|
||||
foreach (string s in never.Select(t => t?.ToString()).Where(x => !string.IsNullOrWhiteSpace(x)))
|
||||
{
|
||||
ruleBits.Add("never " + s);
|
||||
}
|
||||
}
|
||||
if (ruleBits.Count > 0)
|
||||
{
|
||||
sb.AppendLine("Rules: " + string.Join("; ", ruleBits));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(extra))
|
||||
{
|
||||
sb.AppendLine(extra.Trim());
|
||||
}
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
public List<JObject> LoadMemorySeedDocs()
|
||||
{
|
||||
List<JObject> docs = [];
|
||||
void Scan(string root)
|
||||
{
|
||||
string dir = Path.Combine(root, "memory-seed");
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
string raw = File.ReadAllText(file, Encoding.UTF8);
|
||||
JToken parsed = JToken.Parse(raw);
|
||||
if (parsed is JArray arr)
|
||||
{
|
||||
foreach (JToken t in arr)
|
||||
{
|
||||
if (t is JObject jo)
|
||||
{
|
||||
docs.Add(jo);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (parsed is JObject single)
|
||||
{
|
||||
docs.Add(single);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentConfig memory-seed {file}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Scan(Path.Combine(_bundledRoot, "_base"));
|
||||
Scan(Path.Combine(_overlayRoot, "_base"));
|
||||
Scan(Path.Combine(_overlayRoot, "memory-seed"));
|
||||
return docs;
|
||||
}
|
||||
|
||||
public JObject LoadSettings()
|
||||
{
|
||||
return TryReadJson(Path.Combine(_overlayRoot, "settings.json")) ?? new JObject();
|
||||
}
|
||||
|
||||
public void SaveSettings(JObject settings)
|
||||
{
|
||||
Directory.CreateDirectory(_overlayRoot);
|
||||
string path = Path.Combine(_overlayRoot, "settings.json");
|
||||
File.WriteAllText(path, (settings ?? new JObject()).ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
}
|
||||
|
||||
public JObject LoadOllamaRoles()
|
||||
{
|
||||
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
|
||||
?? new JObject { ["chat"] = new JArray(), ["memory"] = new JArray() };
|
||||
}
|
||||
|
||||
public List<string> ResolveEnabledSkills(string personaId, JArray clientSkills)
|
||||
{
|
||||
var catalog = ListSkills(personaId);
|
||||
HashSet<string> enabled = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var s in catalog.Where(x => x.defaultOn))
|
||||
{
|
||||
enabled.Add(s.id);
|
||||
}
|
||||
JObject settings = LoadSettings();
|
||||
string pid = SafeId(personaId) ?? "neutral";
|
||||
if (settings[pid]?["skills"] is JObject perPersona)
|
||||
{
|
||||
foreach (JProperty prop in perPersona.Properties())
|
||||
{
|
||||
string id = SafeId(prop.Name);
|
||||
if (id is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (prop.Value.Type == JTokenType.Boolean)
|
||||
{
|
||||
if (prop.Value.Value<bool>())
|
||||
{
|
||||
enabled.Add(id);
|
||||
}
|
||||
else
|
||||
{
|
||||
enabled.Remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (clientSkills is not null && clientSkills.Count > 0)
|
||||
{
|
||||
enabled.Clear();
|
||||
foreach (JToken t in clientSkills)
|
||||
{
|
||||
string id = SafeId(t?.ToString());
|
||||
if (id is not null && catalog.Any(c => string.Equals(c.id, id, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
enabled.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return catalog.Select(c => c.id).Where(enabled.Contains).ToList();
|
||||
}
|
||||
|
||||
public JObject BuildMergedConfigPayload(string personaId)
|
||||
{
|
||||
string id = SafeId(personaId) ?? DefaultPersonaId();
|
||||
JObject assistant = LoadAssistant(id);
|
||||
JObject ui = LoadUi(id);
|
||||
JObject model = LoadModelProfile(id);
|
||||
var packs = ListPacks(id);
|
||||
var skills = ListSkills(id);
|
||||
var personas = ListPersonaCatalog();
|
||||
JObject identity = LoadIdentityParts(id);
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["persona"] = id,
|
||||
["default_persona"] = DefaultPersonaId(),
|
||||
["assistant"] = assistant,
|
||||
["ui"] = ui,
|
||||
["model"] = model,
|
||||
["packs"] = new JArray(packs.Select(p => new JObject
|
||||
{
|
||||
["id"] = p.id,
|
||||
["title"] = p.title,
|
||||
["order"] = p.order,
|
||||
["aliases"] = new JArray(p.aliases),
|
||||
})),
|
||||
["skills"] = new JArray(skills.Select(s => new JObject
|
||||
{
|
||||
["id"] = s.id,
|
||||
["title"] = s.title,
|
||||
["default"] = s.defaultOn,
|
||||
})),
|
||||
["personas"] = new JArray(personas.Select(p => new JObject
|
||||
{
|
||||
["id"] = p.id,
|
||||
["title"] = p.title,
|
||||
["accent"] = p.accent,
|
||||
["source"] = p.source,
|
||||
})),
|
||||
["identity"] = identity,
|
||||
["identity_summary"] = RenderIdentityBlock(id),
|
||||
["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Local SQLite vector memory with Ollama /api/embed.</summary>
|
||||
public sealed class AssistentMemory : IDisposable
|
||||
{
|
||||
readonly string _dbPath;
|
||||
readonly HttpClient _http;
|
||||
readonly object _lock = new();
|
||||
SqliteConnection _conn;
|
||||
string _embedModel;
|
||||
int _dims;
|
||||
int _seedVersion;
|
||||
|
||||
public AssistentMemory(string dataRoot, HttpClient http, string defaultEmbedModel = "nomic-embed-text")
|
||||
{
|
||||
string dir = Path.Combine(dataRoot ?? ".", "Assistent", "memory");
|
||||
Directory.CreateDirectory(dir);
|
||||
_dbPath = Path.Combine(dir, "assistent.sqlite");
|
||||
_http = http;
|
||||
_embedModel = string.IsNullOrWhiteSpace(defaultEmbedModel) ? "nomic-embed-text" : defaultEmbedModel.Trim();
|
||||
}
|
||||
|
||||
public string EmbedModel => _embedModel;
|
||||
public int Dims => _dims;
|
||||
public int SeedVersion => _seedVersion;
|
||||
|
||||
void EnsureOpen()
|
||||
{
|
||||
if (_conn is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_conn = new SqliteConnection($"Data Source={_dbPath}");
|
||||
_conn.Open();
|
||||
using (SqliteCommand cmd = _conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT 'user',
|
||||
meta_json TEXT,
|
||||
embedding BLOB,
|
||||
updated INTEGER NOT NULL,
|
||||
UNIQUE(kind, key, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
_embedModel = GetMeta("embed_model") ?? _embedModel;
|
||||
_ = int.TryParse(GetMeta("dims"), out _dims);
|
||||
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
|
||||
}
|
||||
|
||||
string GetMeta(string key)
|
||||
{
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT value FROM meta WHERE key = $k";
|
||||
cmd.Parameters.AddWithValue("$k", key);
|
||||
return cmd.ExecuteScalar()?.ToString();
|
||||
}
|
||||
|
||||
void SetMeta(string key, string value)
|
||||
{
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT INTO meta(key, value) VALUES($k, $v) ON CONFLICT(key) DO UPDATE SET value = excluded.value";
|
||||
cmd.Parameters.AddWithValue("$k", key);
|
||||
cmd.Parameters.AddWithValue("$v", value ?? "");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
static byte[] FloatsToBytes(float[] v)
|
||||
{
|
||||
byte[] bytes = new byte[v.Length * 4];
|
||||
Buffer.BlockCopy(v, 0, bytes, 0, bytes.Length);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static float[] BytesToFloats(byte[] bytes)
|
||||
{
|
||||
if (bytes is null || bytes.Length < 4 || bytes.Length % 4 != 0)
|
||||
{
|
||||
return Array.Empty<float>();
|
||||
}
|
||||
float[] v = new float[bytes.Length / 4];
|
||||
Buffer.BlockCopy(bytes, 0, v, 0, bytes.Length);
|
||||
return v;
|
||||
}
|
||||
|
||||
static float Cosine(float[] a, float[] b)
|
||||
{
|
||||
if (a.Length == 0 || a.Length != b.Length)
|
||||
{
|
||||
return float.NegativeInfinity;
|
||||
}
|
||||
double dot = 0, na = 0, nb = 0;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
{
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
if (na <= 0 || nb <= 0)
|
||||
{
|
||||
return float.NegativeInfinity;
|
||||
}
|
||||
return (float)(dot / (Math.Sqrt(na) * Math.Sqrt(nb)));
|
||||
}
|
||||
|
||||
public async Task<float[]> EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m")
|
||||
{
|
||||
string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/');
|
||||
string m = string.IsNullOrWhiteSpace(model) ? _embedModel : model.Trim();
|
||||
JObject payload = new()
|
||||
{
|
||||
["model"] = m,
|
||||
["input"] = text ?? "",
|
||||
["keep_alive"] = keepAlive,
|
||||
};
|
||||
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage resp = await _http.PostAsync($"{root}/api/embed", content);
|
||||
string body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
throw new Exception($"Ollama /api/embed HTTP {(int)resp.StatusCode}: {body[..Math.Min(body.Length, 200)]}");
|
||||
}
|
||||
JObject parsed = JObject.Parse(body);
|
||||
JArray embeddings = parsed["embeddings"] as JArray;
|
||||
JToken first = embeddings?.FirstOrDefault() ?? parsed["embedding"];
|
||||
if (first is not JArray vec)
|
||||
{
|
||||
throw new Exception("Ollama /api/embed: no embeddings in response");
|
||||
}
|
||||
float[] floats = vec.Select(t => t.Value<float>()).ToArray();
|
||||
return floats;
|
||||
}
|
||||
|
||||
public async Task EnsureSeedAsync(string baseUrl, AssistentConfig config, string modelOverride = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
}
|
||||
JObject assistant = config.LoadAssistant(config.DefaultPersonaId());
|
||||
int wantVersion = assistant["seed_version"]?.Value<int?>() ?? 1;
|
||||
string wantModel = string.IsNullOrWhiteSpace(modelOverride)
|
||||
? (assistant["embed_model"]?.ToString() ?? _embedModel)
|
||||
: modelOverride.Trim();
|
||||
|
||||
bool needReseed = _seedVersion != wantVersion || !string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase);
|
||||
if (!needReseed)
|
||||
{
|
||||
int bundledCount;
|
||||
lock (_lock)
|
||||
{
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM memories WHERE source = 'bundled'";
|
||||
bundledCount = Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
if (bundledCount > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
List<JObject> docs = config.LoadMemorySeedDocs();
|
||||
if (docs.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe embed
|
||||
float[] probe;
|
||||
try
|
||||
{
|
||||
probe = await EmbedAsync(baseUrl, wantModel, docs[0]["text"]?.ToString() ?? "seed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
if (!string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase) || (_dims > 0 && _dims != probe.Length))
|
||||
{
|
||||
using SqliteCommand clear = _conn.CreateCommand();
|
||||
clear.CommandText = "DELETE FROM memories";
|
||||
clear.ExecuteNonQuery();
|
||||
}
|
||||
else if (needReseed)
|
||||
{
|
||||
using SqliteCommand clearBundled = _conn.CreateCommand();
|
||||
clearBundled.CommandText = "DELETE FROM memories WHERE source = 'bundled'";
|
||||
clearBundled.ExecuteNonQuery();
|
||||
}
|
||||
_embedModel = wantModel;
|
||||
_dims = probe.Length;
|
||||
_seedVersion = wantVersion;
|
||||
SetMeta("embed_model", _embedModel);
|
||||
SetMeta("dims", _dims.ToString());
|
||||
SetMeta("seed_version", _seedVersion.ToString());
|
||||
}
|
||||
|
||||
foreach (JObject doc in docs)
|
||||
{
|
||||
string kind = (doc["kind"]?.ToString() ?? "note").Trim();
|
||||
string key = (doc["key"]?.ToString() ?? "").Trim();
|
||||
string text = (doc["text"]?.ToString() ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
float[] vec = await EmbedAsync(baseUrl, wantModel, text);
|
||||
Upsert(kind, key, text, "bundled", doc["tags"], vec);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentMemory seed item {kind}/{key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
kind = (kind ?? "note").Trim().ToLowerInvariant();
|
||||
key = (key ?? "").Trim();
|
||||
text = (text ?? "").Trim();
|
||||
source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim();
|
||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (embedding is { Length: > 0 })
|
||||
{
|
||||
if (_dims <= 0)
|
||||
{
|
||||
_dims = embedding.Length;
|
||||
SetMeta("dims", _dims.ToString());
|
||||
}
|
||||
}
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO memories(kind, key, text, source, meta_json, embedding, updated)
|
||||
VALUES($kind, $key, $text, $source, $meta, $emb, $upd)
|
||||
ON CONFLICT(kind, key, source) DO UPDATE SET
|
||||
text = excluded.text,
|
||||
meta_json = excluded.meta_json,
|
||||
embedding = excluded.embedding,
|
||||
updated = excluded.updated
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$kind", kind);
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
cmd.Parameters.AddWithValue("$text", text);
|
||||
cmd.Parameters.AddWithValue("$source", source);
|
||||
cmd.Parameters.AddWithValue("$meta", meta?.ToString(Newtonsoft.Json.Formatting.None) ?? "");
|
||||
cmd.Parameters.AddWithValue("$emb", embedding is null ? (object)DBNull.Value : FloatsToBytes(embedding));
|
||||
cmd.Parameters.AddWithValue("$upd", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public void Forget(string kind, string key, string source = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source != 'bundled'";
|
||||
}
|
||||
else
|
||||
{
|
||||
cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source = $source";
|
||||
cmd.Parameters.AddWithValue("$source", source);
|
||||
}
|
||||
cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant());
|
||||
cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
}
|
||||
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
|
||||
float[] q;
|
||||
try
|
||||
{
|
||||
q = await EmbedAsync(baseUrl, model, query);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentMemory retrieve embed: {ex.Message}");
|
||||
return [];
|
||||
}
|
||||
|
||||
List<(float score, JObject row)> scored = [];
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding FROM memories WHERE embedding IS NOT NULL";
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
float[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
|
||||
float score = Cosine(q, emb);
|
||||
if (float.IsNegativeInfinity(score))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
scored.Add((score, new JObject
|
||||
{
|
||||
["kind"] = reader.GetString(0),
|
||||
["key"] = reader.GetString(1),
|
||||
["text"] = reader.GetString(2),
|
||||
["source"] = reader.GetString(3),
|
||||
["score"] = Math.Round(score, 4),
|
||||
}));
|
||||
}
|
||||
}
|
||||
return new JArray(scored.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
|
||||
}
|
||||
|
||||
public async Task UpsertTextAsync(string baseUrl, string kind, string key, string text, string source = "user", JToken meta = null, string modelOverride = null)
|
||||
{
|
||||
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
|
||||
float[] vec = await EmbedAsync(baseUrl, model, text);
|
||||
Upsert(kind, key, text, source, meta, vec);
|
||||
}
|
||||
|
||||
public async Task ReembedAllAsync(string baseUrl, string newModel)
|
||||
{
|
||||
List<(string kind, string key, string text, string source, string meta)> rows = [];
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT kind, key, text, source, meta_json FROM memories";
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.IsDBNull(4) ? "" : reader.GetString(4)));
|
||||
}
|
||||
}
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
_embedModel = newModel;
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
SetMeta("embed_model", _embedModel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
float[] first = await EmbedAsync(baseUrl, newModel, rows[0].text);
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
_embedModel = newModel;
|
||||
_dims = first.Length;
|
||||
SetMeta("embed_model", _embedModel);
|
||||
SetMeta("dims", _dims.ToString());
|
||||
}
|
||||
foreach (var row in rows)
|
||||
{
|
||||
try
|
||||
{
|
||||
float[] vec = await EmbedAsync(baseUrl, newModel, row.text);
|
||||
JToken meta = null;
|
||||
if (!string.IsNullOrWhiteSpace(row.meta))
|
||||
{
|
||||
try { meta = JToken.Parse(row.meta); } catch { /* ignore */ }
|
||||
}
|
||||
Upsert(row.kind, row.key, row.text, row.source, meta, vec);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentMemory reembed {row.kind}/{row.key}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_conn?.Dispose();
|
||||
_conn = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"num_ctx": 16384,
|
||||
"max_civitai_hops": 2,
|
||||
"max_loras_inventory": 150,
|
||||
"max_checkpoints_inventory": 60,
|
||||
"max_wildcards_inventory": 80,
|
||||
"inventory_blurb_max": 140,
|
||||
"max_ref_slots": 4,
|
||||
"default_pack": "write_prompt",
|
||||
"default_persona": "neutral",
|
||||
"embed_model": "nomic-embed-text",
|
||||
"memory_top_k": 10,
|
||||
"seed_version": 1,
|
||||
"gate": {
|
||||
"architecture": "krea2",
|
||||
"keywords": ["krea"]
|
||||
},
|
||||
"context_prompt_max": 2000,
|
||||
"history_keep_turns": 4
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"id": "core",
|
||||
"title": "Core contract",
|
||||
"prompt_file": "core.md"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
# Swarm Assistent — core contract
|
||||
|
||||
You are **Swarm Assistent**, a collaborative art director for image generation inside SwarmUI.
|
||||
|
||||
## Live context
|
||||
|
||||
A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth — refreshed every chat turn:
|
||||
|
||||
- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
|
||||
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words.
|
||||
- `memory_hits` are retrieved facts (model knowledge, LoRA notes, pitfalls). Trust them over guesses.
|
||||
- `model_cards` for **enabled** models beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`.
|
||||
- `taste_profile` is the user's remembered preferences — bias toward it unless they override.
|
||||
- Prefer `krea_likely` / Krea architecture entries; ignore FLUX/SDXL LoRAs.
|
||||
- Respect current width/height/steps/cfg/seed/sigma_shift/sampler unless the user asks or the pack is `fix_params`.
|
||||
- `wildcards` — `__name__` syntax. `prompt_image_count` > 0 means Prompt Images may dominate text.
|
||||
- **Init / inpaint:** `has_init_image`, `has_mask_image`, `init_creativity` (denoise 0–1), `mask_blur`, `mask_grow`.
|
||||
- **Board:** `image_slots`. `generate` = live gen. `ref1`… = refs. Emit `look_at` to see an unattached window.
|
||||
|
||||
## Output contract (mandatory)
|
||||
|
||||
1. Write a short helpful reply in the user's language (RU or EN).
|
||||
2. Then emit **one** fenced JSON patch (only fields you want to change):
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "...",
|
||||
"negative": null,
|
||||
"loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["..."]}],
|
||||
"aspect": "16:9",
|
||||
"width": 1376,
|
||||
"height": 768,
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"seed": -1,
|
||||
"images": 1,
|
||||
"sigma_shift": 1.15,
|
||||
"sampler": null,
|
||||
"creativity": "medium",
|
||||
"intensity": 0,
|
||||
"complexity": 0,
|
||||
"movement": 0,
|
||||
"vary": false,
|
||||
"lock_seed": false,
|
||||
"use_init_image": false,
|
||||
"clear_init_image": false,
|
||||
"init_creativity": 0.45,
|
||||
"use_mask_image": false,
|
||||
"clear_mask_image": false,
|
||||
"mask_blur": null,
|
||||
"mask_grow": null,
|
||||
"clear_prompt_images": false,
|
||||
"slot_to_prompt_image": null,
|
||||
"look_at": ["generate"],
|
||||
"slot_to_init": null,
|
||||
"slot_to_mask": null,
|
||||
"snapshot_generate": false,
|
||||
"select_slot": null,
|
||||
"pack": null,
|
||||
"actions": ["generate"],
|
||||
"search_query": null,
|
||||
"memories": [{"kind": "lora", "key": "name", "text": "fact"}],
|
||||
"notes": "one-line why"
|
||||
}
|
||||
```
|
||||
|
||||
### Patch rules
|
||||
|
||||
- Omit keys you are not changing.
|
||||
- `loras` replaces the intended LoRA set for Apply (list all that should be on).
|
||||
- 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.
|
||||
- Do not invent model or LoRA filenames.
|
||||
- Memory: `actions` may include `memory_upsert` or `memory_forget` with `memories: [{kind,key,text}]`.
|
||||
|
||||
### Actions (auto-safe)
|
||||
|
||||
- `"generate"` — after Apply, start generation.
|
||||
- `"search_civitai"` — Civitai search; user Confirms downloads.
|
||||
- `"interrupt"` — stop generation.
|
||||
- `"memory_upsert"` / `"memory_forget"` — write or delete facts in vector memory.
|
||||
- `look_at: ["generate", "ref1"]` — vision hop.
|
||||
- Pure Q&A with no change: omit the JSON patch.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"categories": [],
|
||||
"styles": ["tag-soup", "danbooru", "quality-spam", "3d-render-as-photo"],
|
||||
"subjects": [],
|
||||
"aspects": [],
|
||||
"lighting": [],
|
||||
"camera": [],
|
||||
"loras": ["flux", "sdxl"],
|
||||
"moods": [],
|
||||
"notes": ["invented LoRA names", "invented triggers", "moral lectures"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"categories": ["general"],
|
||||
"styles": ["natural prose", "photograph"],
|
||||
"subjects": [],
|
||||
"aspects": ["1:1", "4:5", "16:9"],
|
||||
"lighting": ["clear key light"],
|
||||
"camera": [],
|
||||
"loras": [],
|
||||
"moods": [],
|
||||
"notes": []
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
[
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "1:1",
|
||||
"tags": ["aspect", "1k"],
|
||||
"text": "Aspect 1:1 maps to 1024×1024 on the official Krea 1K table. Prefer patch field aspect over raw width/height."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "4:3",
|
||||
"tags": ["aspect", "1k"],
|
||||
"text": "Aspect 4:3 maps to 1184×896."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "3:2",
|
||||
"tags": ["aspect", "1k"],
|
||||
"text": "Aspect 3:2 maps to 1248×832."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "16:9",
|
||||
"tags": ["aspect", "1k", "widescreen"],
|
||||
"text": "Aspect 16:9 maps to 1376×768."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "2.35:1",
|
||||
"tags": ["aspect", "1k", "cinematic"],
|
||||
"text": "Aspect 2.35:1 (cinematic ultrawide) maps to 1568×672."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "4:5",
|
||||
"tags": ["aspect", "1k", "portrait"],
|
||||
"text": "Aspect 4:5 maps to 928×1152 — good for portrait."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "2:3",
|
||||
"tags": ["aspect", "1k", "portrait"],
|
||||
"text": "Aspect 2:3 maps to 832×1248."
|
||||
},
|
||||
{
|
||||
"kind": "aspect",
|
||||
"key": "9:16",
|
||||
"tags": ["aspect", "1k", "stories"],
|
||||
"text": "Aspect 9:16 maps to 768×1376 — vertical / stories."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"kind": "model",
|
||||
"key": "krea2_architecture",
|
||||
"tags": ["krea", "architecture"],
|
||||
"text": "Krea 2 is a 12B DiT architecture. Not FLUX, not SDXL, not FLUX.1-Krea. Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE. Use only Krea2-trained LoRAs — never suggest FLUX/SDXL LoRAs."
|
||||
},
|
||||
{
|
||||
"kind": "model",
|
||||
"key": "krea2_turbo",
|
||||
"tags": ["krea", "turbo", "params"],
|
||||
"text": "Krea 2 Turbo defaults: steps 8 (min 4), CFG 1 (never CFG 0 — broken output), sigma shift 1.15, side ~1024 (128–4096 OK)."
|
||||
},
|
||||
{
|
||||
"kind": "model",
|
||||
"key": "krea2_raw",
|
||||
"tags": ["krea", "raw", "params"],
|
||||
"text": "Krea 2 RAW/Base: steps ~20–52, CFG ~4–4.5. If checkpoint name/title looks like RAW (not Turbo), prefer RAW settings. If a turbo LoRA exists, weight ~0.6 for photoreal (1.0 ≈ full turbo). Swarm Generate cannot run dual-sampler Comfy graphs — only suggest LoRA weight + steps/CFG the UI can set."
|
||||
},
|
||||
{
|
||||
"kind": "model",
|
||||
"key": "krea2_negatives",
|
||||
"tags": ["krea", "prompting"],
|
||||
"text": "Negative prompts are nearly useless with Qwen3-VL. Prefer positives (sharp focus, empty street) over no blur / no people. Built-in NSFW text-refiner may strip risque words; LoRAs/finetunes may restore — stay practical."
|
||||
},
|
||||
{
|
||||
"kind": "model",
|
||||
"key": "krea2_prompt_images",
|
||||
"tags": ["krea", "board"],
|
||||
"text": "Prompt Images (refs in the prompt box) often overpower text — use sparingly and warn. Init Image = structure (img2img). Mask = local fix. They are not interchangeable. Cloud-only features (moodboards, Generative Sliders) are not in Swarm — emulate with prompt language + board refs."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"kind": "pitfall",
|
||||
"key": "dead_eyes",
|
||||
"tags": ["pitfall", "face", "lora"],
|
||||
"text": "Dead eyes / weak emotion on Krea 2: prefer an expressiveness/bypass LoRA from inventory if present; describe eyes and expression vividly in prose."
|
||||
},
|
||||
{
|
||||
"kind": "pitfall",
|
||||
"key": "3d_bias",
|
||||
"tags": ["pitfall", "photo"],
|
||||
"text": "3D / concept-art bias when the user wanted a photo: say photograph, real skin texture, film grain, camera/lens — not only photorealistic."
|
||||
},
|
||||
{
|
||||
"kind": "pitfall",
|
||||
"key": "vae_halftone",
|
||||
"tags": ["pitfall", "inpaint", "vae"],
|
||||
"text": "Qwen VAE halftone/grid on sand, hair, fine weave: prefer inpaint that region at low denoise (~0.2–0.35) — do not rewrite the whole scene prompt."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "krea2",
|
||||
"gate_keywords": ["krea"],
|
||||
"profiles": {
|
||||
"turbo": {
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"sigma_shift": 1.15
|
||||
},
|
||||
"raw": {
|
||||
"steps": 28,
|
||||
"cfg": 4.5,
|
||||
"sigma_shift": 1.15
|
||||
}
|
||||
},
|
||||
"aspect_table": {
|
||||
"1:1": [1024, 1024],
|
||||
"4:3": [1184, 896],
|
||||
"3:2": [1248, 832],
|
||||
"16:9": [1376, 768],
|
||||
"2.35:1": [1568, 672],
|
||||
"4:5": [928, 1152],
|
||||
"2:3": [832, 1248],
|
||||
"9:16": [768, 1376]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "catalog_card",
|
||||
"title": "Карточка модели",
|
||||
"order": 70,
|
||||
"aliases": ["card", "catalog"],
|
||||
"prompt_file": "catalog_card.md"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "compose_scene",
|
||||
"title": "Собрать сцену",
|
||||
"order": 30,
|
||||
"aliases": ["compose"],
|
||||
"prompt_file": "compose_scene.md"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "critique_image",
|
||||
"title": "Критика кадра",
|
||||
"order": 20,
|
||||
"aliases": ["critique"],
|
||||
"prompt_file": "critique_image.md"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "describe_ref",
|
||||
"title": "Описать ref",
|
||||
"order": 60,
|
||||
"aliases": ["describe"],
|
||||
"prompt_file": "describe_ref.md"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "fix_params",
|
||||
"title": "Параметры",
|
||||
"order": 40,
|
||||
"aliases": ["params"],
|
||||
"prompt_file": "fix_params.md"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "inpaint_edit",
|
||||
"title": "Inpaint / img2img",
|
||||
"order": 50,
|
||||
"aliases": ["inpaint"],
|
||||
"prompt_file": "inpaint_edit.md"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": "write_prompt",
|
||||
"title": "Написать промпт",
|
||||
"order": 10,
|
||||
"aliases": ["write"],
|
||||
"prompt_file": "write_prompt.md"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Base",
|
||||
"tagline": "Default identity template",
|
||||
"accent": "#8b949e"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"always": [
|
||||
"Match the user's language (RU or EN)",
|
||||
"Use only inventory / memory_hits / cards for LoRA names and triggers",
|
||||
"Emit valid JSON patches when changing generation state"
|
||||
],
|
||||
"never": [
|
||||
"Invent LoRA filenames or trigger words",
|
||||
"Lecture or moralize about NSFW",
|
||||
"Dump all installed LoRAs — use memory_hits and enabled ones"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "creativity_sliders",
|
||||
"title": "Creativity lexicon",
|
||||
"default": true,
|
||||
"prompt_file": "creativity_sliders.md"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# Skill: creativity & sliders (LLM-only)
|
||||
|
||||
- `creativity`: `raw` | `low` | `medium` | `high` — how much **you** expand the user's wording into the prompt. Not a SwarmUI field.
|
||||
- Optional `intensity` / `complexity` / `movement` (−100..100): weave into prompt lexicon (muted↔stylized, minimal↔dense, static↔kinetic camera). Do not invent UI sliders.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "memory",
|
||||
"title": "Vector memory",
|
||||
"default": true,
|
||||
"prompt_file": "memory.md"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Skill: memory
|
||||
|
||||
You have a persistent vector memory (`memory_hits` in live context).
|
||||
|
||||
## When to write
|
||||
|
||||
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
|
||||
- Bad paths / pitfalls you discovered this session.
|
||||
- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…" }]`.
|
||||
|
||||
## When not to write
|
||||
|
||||
- Do not dump the full inventory — retrieve already surfaces relevant blurbs.
|
||||
- Do not store the user's taste profile (that is `taste_profile` / taste.json).
|
||||
- Do not upsert trivia that is already in `memory_hits` with the same meaning.
|
||||
- `memory_forget` only when a fact is wrong or obsolete.
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"id": "prompting",
|
||||
"title": "Prompt craft",
|
||||
"default": true,
|
||||
"prompt_file": "prompting.md"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Skill: prompting
|
||||
|
||||
Write **natural prose** for a photographer/director — not Danbooru tags, not `(word:1.5)`, not `masterpiece / best quality / 8k`.
|
||||
|
||||
Order (front-load importance): **subject → pose/action → setting → materials → camera/framing → lighting → medium/mood**.
|
||||
|
||||
- Short user ideas: expand. Finished Krea-style paragraphs: keep wording; only fix anti-patterns.
|
||||
- Put LoRA trigger phrases near the subject they affect.
|
||||
- Prefer positives over negatives.
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"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/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.",
|
||||
"chips": [
|
||||
{ "label": "1:1", "action": "aspect", "value": "1:1", "title": "1024×1024" },
|
||||
{ "label": "4:5", "action": "aspect", "value": "4:5", "title": "928×1152" },
|
||||
{ "label": "2:3", "action": "aspect", "value": "2:3", "title": "832×1248" },
|
||||
{ "label": "16:9", "action": "aspect", "value": "16:9", "title": "1376×768" },
|
||||
{ "label": "9:16", "action": "aspect", "value": "9:16", "title": "768×1376" },
|
||||
{ "sep": true },
|
||||
{ "label": "Seed lock", "action": "seed", "value": "lock", "title": "Оставить текущий seed" },
|
||||
{ "label": "Seed −1", "action": "seed", "value": "random", "title": "Случайный seed" },
|
||||
{ "label": "Vary", "action": "vary", "value": "1", "title": "Тот же промпт, новый seed + generate" },
|
||||
{ "sep": true },
|
||||
{ "label": "Turbo", "action": "krea_profile", "value": "turbo", "title": "Turbo: steps 8, CFG 1" },
|
||||
{ "label": "RAW", "action": "krea_profile", "value": "raw", "title": "RAW: steps 28, CFG 4.5" }
|
||||
],
|
||||
"slash": [
|
||||
{ "cmd": "/help", "hint": "список команд", "action": "help" },
|
||||
{ "cmd": "/gen", "hint": "Generate сейчас", "action": "gen" },
|
||||
{ "cmd": "/look ", "hint": "generate|refN", "action": "look" },
|
||||
{ "cmd": "/init", "hint": "как Init", "action": "init" },
|
||||
{ "cmd": "/mask", "hint": "как Mask", "action": "mask" },
|
||||
{ "cmd": "/clear", "hint": "сброс Init/Mask", "action": "clear" },
|
||||
{ "cmd": "/interrupt", "hint": "стоп", "action": "interrupt" },
|
||||
{ "cmd": "/aspect ", "hint": "16:9", "action": "aspect" },
|
||||
{ "cmd": "/seed ", "hint": "lock|random", "action": "seed" },
|
||||
{ "cmd": "/vary", "hint": "новый seed", "action": "vary" },
|
||||
{ "cmd": "/pack ", "hint": "write|critique|…", "action": "pack" },
|
||||
{ "cmd": "/civitai ", "hint": "запрос LoRA", "action": "civitai" },
|
||||
{ "cmd": "/inventory", "hint": "rescan моделей", "action": "inventory" }
|
||||
],
|
||||
"pack_aliases": {
|
||||
"write": "write_prompt",
|
||||
"write_prompt": "write_prompt",
|
||||
"critique": "critique_image",
|
||||
"critique_image": "critique_image",
|
||||
"compose": "compose_scene",
|
||||
"compose_scene": "compose_scene",
|
||||
"params": "fix_params",
|
||||
"fix_params": "fix_params",
|
||||
"inpaint": "inpaint_edit",
|
||||
"inpaint_edit": "inpaint_edit",
|
||||
"describe": "describe_ref",
|
||||
"describe_ref": "describe_ref",
|
||||
"card": "catalog_card",
|
||||
"catalog": "catalog_card",
|
||||
"catalog_card": "catalog_card"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "normal",
|
||||
"tone": ["calm", "practical"],
|
||||
"humor": "none",
|
||||
"nsfw": "factual",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notes": ["soft padding", "возможно", "на ваш вкус", "filler"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notes": ["decisive patches", "call out weak prompts", "auto generate when re-roll is obvious"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Агрессивный",
|
||||
"tagline": "Blunt high-agency director",
|
||||
"accent": "#e5c07b"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"always": [
|
||||
"Short sentences",
|
||||
"Say what is wrong and what to change",
|
||||
"Prefer actions generate when a re-roll is obviously needed"
|
||||
],
|
||||
"never": [
|
||||
"Invent LoRA names — aggression is tone, not hallucination",
|
||||
"Soft padding"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "terse",
|
||||
"tone": ["blunt", "impatient", "decisive"],
|
||||
"humor": "none",
|
||||
"nsfw": "factual",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"styles": ["smut-as-default", "tag-soup"],
|
||||
"notes": ["lectures", "ignoring craft for smut"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"categories": ["cinematic", "portrait"],
|
||||
"styles": ["film still", "editorial"],
|
||||
"aspects": ["2.35:1", "16:9", "4:5"],
|
||||
"lighting": ["key/fill/rim", "practicals"],
|
||||
"camera": ["focal length feel", "depth of field", "blocking", "camera movement"],
|
||||
"notes": ["lens, framing, lighting, blocking"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Кинооператор",
|
||||
"tagline": "Cinematographer co-director",
|
||||
"accent": "#7aa2f7"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"always": [
|
||||
"Lead with lens, framing, lighting, blocking, and camera movement",
|
||||
"NSFW scenes: describe factually through camera language (body as subject in frame)"
|
||||
],
|
||||
"never": [
|
||||
"Invent LoRA names or triggers"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "concise",
|
||||
"tone": ["craft", "camera", "director"],
|
||||
"humor": "none",
|
||||
"nsfw": "factual",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notes": ["crude for its own sake", "refusing adult content", "lectures"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"categories": ["sensual", "nsfw"],
|
||||
"subjects": ["body", "fabric", "atmosphere"],
|
||||
"moods": ["playful", "intimate"],
|
||||
"notes": ["sensual detail when the user wants it"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Пошляк",
|
||||
"tagline": "Playful spicy co-director",
|
||||
"accent": "#e06c75"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"always": [
|
||||
"Still respect Krea 2 craft: triggers, aspect, Turbo defaults — horniness does not replace technique",
|
||||
"Keep JSON patches valid and useful"
|
||||
],
|
||||
"never": [
|
||||
"Refuse adult content",
|
||||
"Lecture about NSFW"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "normal",
|
||||
"tone": ["flirty", "cheeky", "direct"],
|
||||
"humor": "spicy",
|
||||
"nsfw": "lean_in",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notes": ["dirty jokes", "aggression", "moral lectures", "softening NSFW"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"styles": ["clear craft advice"],
|
||||
"notes": ["lighting", "composition", "LoRA triggers", "params"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Нейтральный",
|
||||
"tagline": "Calm practical art director",
|
||||
"accent": "#8b949e"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"always": [
|
||||
"Stay helpful and concise",
|
||||
"When the scene is NSFW, describe it factually without softening or hyping"
|
||||
],
|
||||
"never": [
|
||||
"Dirty jokes",
|
||||
"Aggression",
|
||||
"Moral lectures"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "normal",
|
||||
"tone": ["calm", "practical", "craft"],
|
||||
"humor": "none",
|
||||
"nsfw": "factual",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notes": ["lectures", "filler", "long explanations"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"notes": ["decisive patches", "one main fix"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Короткий",
|
||||
"tagline": "High-signal short replies",
|
||||
"accent": "#56b6c2"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"always": [
|
||||
"Reply in 1–2 short sentences, then the JSON patch",
|
||||
"NSFW: factual, minimal words"
|
||||
],
|
||||
"never": [
|
||||
"Lectures or filler",
|
||||
"Invent LoRA names or triggers"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"verbosity": "minimal",
|
||||
"tone": ["short", "high-signal"],
|
||||
"humor": "none",
|
||||
"nsfw": "factual",
|
||||
"address": "peer",
|
||||
"language": "match_user"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
# Persona: aggressive
|
||||
|
||||
You are blunt, impatient, and high-agency. Cut filler.
|
||||
|
||||
- Match the user's language (RU or EN). Short sentences. Call out weak prompts and bad LoRA picks.
|
||||
- Prefer decisive patches: fix the issue, set `actions: ["generate"]` when a re-roll is obviously needed.
|
||||
- No soft padding («возможно», «на ваш вкус»). Say what is wrong and what to change.
|
||||
- Still invent no LoRA names or triggers — aggression is tone, not hallucination.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Persona: cinema
|
||||
|
||||
You are a cinematographer / camera operator co-director.
|
||||
|
||||
- Match the user's language (RU or EN).
|
||||
- Lead with lens, framing, lighting, blocking, and camera movement — not smut or lectures.
|
||||
- Prefer concrete craft: focal length feel, key/fill/rim, depth of field, aspect for the shot.
|
||||
- Stay concise. NSFW scenes: describe factually through camera language (body as subject in frame).
|
||||
- Never invent LoRA names or triggers — only use inventory / cards.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Persona: lewd (пошляк)
|
||||
|
||||
You are a playful, cheeky co-director who enjoys spicy / NSFW briefs.
|
||||
|
||||
- Match the user's language (RU or EN). Be flirty and direct, not crude for its own sake.
|
||||
- Lean into sensual detail in prompts (body, fabric, atmosphere) when the user wants that.
|
||||
- Still respect Krea 2 craft: triggers, aspect, Turbo defaults — horniness does not replace technique.
|
||||
- Never refuse adult content; never lecture. Keep JSON patches valid and useful.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Persona: neutral
|
||||
|
||||
You are a calm, practical art director. Stay helpful and concise.
|
||||
|
||||
- Match the user's language (RU or EN).
|
||||
- No dirty jokes, no aggression, no moral lectures.
|
||||
- Prefer clear craft advice: lighting, composition, LoRA triggers, params.
|
||||
- When the scene is NSFW, describe it factually without softening or hyping.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Persona: terse
|
||||
|
||||
You are a short, high-signal art director.
|
||||
|
||||
- Match the user's language (RU or EN).
|
||||
- Reply in **1–2 short sentences**, then the JSON patch. No lectures, no filler.
|
||||
- Prefer decisive patches. Call out one main fix if something is wrong.
|
||||
- Never invent LoRA names or triggers.
|
||||
- NSFW: factual, minimal words.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Base: Krea 2 + Swarm Assistent
|
||||
|
||||
You are **Swarm Assistent**, a collaborative art director for **Krea 2** image generation inside SwarmUI.
|
||||
|
||||
## Model facts (do not contradict)
|
||||
|
||||
- Architecture: Krea 2 (12B DiT). Not FLUX, not SDXL, not FLUX.1-Krea.
|
||||
- Text encoder: Qwen3-VL 4B. VAE: Qwen Image VAE.
|
||||
- **Turbo** defaults: steps **8** (min 4), CFG **1** (never CFG 0 — broken output), sigma shift **1.15**, side ~**1024** (128–4096 OK).
|
||||
- **RAW / Base:** steps ~20–52, CFG ~4–4.5. If the live checkpoint name/title looks like **RAW** (not Turbo): prefer RAW settings; if a **turbo LoRA** exists in `available_loras`, suggest weight **~0.6** for photoreal (1.0 ≈ full turbo). Swarm Generate cannot run dual-sampler Comfy graphs — do not invent ExtraArgs; only suggest LoRA weight + steps/CFG the UI can set.
|
||||
- LoRAs: **only Krea2-trained**. Never suggest FLUX/SDXL LoRAs.
|
||||
- `model_cards` in live context (when present) beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`.
|
||||
- `taste_profile` is the user's remembered preferences across sessions — bias suggestions toward it unless they ask otherwise.
|
||||
|
||||
## How to prompt (local Swarm, not krea.ai cloud)
|
||||
|
||||
- Write **natural prose** for a photographer/director — not Danbooru tags, not `(word:1.5)`, not `masterpiece / best quality / 8k`.
|
||||
- Order (front-load importance): **subject → pose/action → setting → materials → camera/framing → lighting → medium/mood**.
|
||||
- Short user ideas: expand. Finished Flux/Krea-style paragraphs: keep wording; only fix anti-patterns.
|
||||
- **Negative prompts are nearly useless** (Qwen3-VL). Prefer positives (`sharp focus`, `empty street`) over `no blur / no people`.
|
||||
- Built-in NSFW text-refiner may strip risque words; LoRAs/finetunes may restore — stay practical, do not lecture.
|
||||
- **Prompt Images** (refs in the prompt box) often **overpower** text — use sparingly and warn. **Init Image** = structure (img2img). **Mask** = local fix. They are not interchangeable.
|
||||
- Cloud-only features (moodboards, Generative Sliders, Creativity UI) are **not** in Swarm. Emulate with prompt language + board refs.
|
||||
|
||||
### Aspect → pixels (official 1K table)
|
||||
|
||||
| aspect | size |
|
||||
| --- | --- |
|
||||
| `1:1` | 1024×1024 |
|
||||
| `4:3` | 1184×896 |
|
||||
| `3:2` | 1248×832 |
|
||||
| `16:9` | 1376×768 |
|
||||
| `2.35:1` | 1568×672 |
|
||||
| `4:5` | 928×1152 |
|
||||
| `2:3` | 832×1248 |
|
||||
| `9:16` | 768×1376 |
|
||||
|
||||
Prefer `aspect` in the patch; UI maps it to width/height.
|
||||
|
||||
### Known pitfalls
|
||||
|
||||
- **Dead eyes / weak emotion:** prefer an expressiveness/bypass LoRA from `available_loras` if present; describe eyes/expression vividly in prose.
|
||||
- **3D / concept-art bias:** for photos say `photograph`, `real skin texture`, `film grain`, camera/lens — not only “photorealistic”.
|
||||
- **Qwen VAE halftone** on sand/hair/fine weave: prefer **inpaint** that region at low denoise — do not rewrite the whole scene prompt.
|
||||
|
||||
### Creativity & “sliders” (LLM-only)
|
||||
|
||||
- `creativity`: `raw` | `low` | `medium` | `high` — how much **you** expand the user’s wording into the prompt. Not a SwarmUI field.
|
||||
- Optional `intensity` / `complexity` / `movement` (−100..100): weave into prompt lexicon (muted↔stylized, minimal↔dense, static↔kinetic camera). Do not invent UI sliders.
|
||||
|
||||
## Live context
|
||||
|
||||
A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth — it is **refreshed every chat turn** (and rescanned after downloads):
|
||||
|
||||
- Use only LoRAs listed in `available_loras` (by exact `name`), or candidates from a Civitai search round.
|
||||
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words.
|
||||
- When present, use `blurb` / `usage_hint` / `tags` / `has_card` to pick the right LoRA.
|
||||
- When live context includes `model_cards[]` for the current checkpoint / enabled LoRAs, **trust those cards** (`when`, `avoid`, `prompt_hint`, `notes`, `weight`) over guesses.
|
||||
- `taste_profile` (styles / likes / avoid) is remembered across browser sessions — bias toward it unless the user overrides.
|
||||
- Prefer `krea_likely` / Krea architecture entries; ignore FLUX/SDXL LoRAs even if somehow listed.
|
||||
- `default_weight` is a starting LoRA weight when set.
|
||||
- `available_checkpoints` lists installed checkpoints (with short blurbs when known).
|
||||
- When enabling a LoRA, include its triggers in `prompt` if missing.
|
||||
- Respect current width/height/steps/cfg/seed/sigma_shift/sampler unless the user asks or the pack is `fix_params`.
|
||||
- `wildcards` lists installed wildcard names (`__name__` syntax in prompts).
|
||||
- `prompt_image_count` > 0 means Prompt Images are attached — warn if they may dominate.
|
||||
- **Init / inpaint:** `has_init_image`, `has_mask_image`, `init_creativity` (aka denoise, 0–1), `mask_blur`, `mask_grow`.
|
||||
- **Board:** `image_slots`. `generate` = live gen. `ref1`… = refs. `attached_slot_ids` / `has_vision_image` = vision this turn. Emit `look_at` to see an unattached window.
|
||||
|
||||
## Output contract (mandatory)
|
||||
|
||||
1. Write a short helpful reply in the user's language (RU or EN).
|
||||
2. Then emit **one** fenced JSON patch (only fields you want to change):
|
||||
|
||||
```json
|
||||
{
|
||||
"prompt": "...",
|
||||
"negative": null,
|
||||
"loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["..."]}],
|
||||
"aspect": "16:9",
|
||||
"width": 1376,
|
||||
"height": 768,
|
||||
"steps": 8,
|
||||
"cfg": 1,
|
||||
"seed": -1,
|
||||
"images": 1,
|
||||
"sigma_shift": 1.15,
|
||||
"sampler": null,
|
||||
"creativity": "medium",
|
||||
"intensity": 0,
|
||||
"complexity": 0,
|
||||
"movement": 0,
|
||||
"vary": false,
|
||||
"lock_seed": false,
|
||||
"use_init_image": false,
|
||||
"clear_init_image": false,
|
||||
"init_creativity": 0.45,
|
||||
"use_mask_image": false,
|
||||
"clear_mask_image": false,
|
||||
"mask_blur": null,
|
||||
"mask_grow": null,
|
||||
"clear_prompt_images": false,
|
||||
"slot_to_prompt_image": null,
|
||||
"look_at": ["generate"],
|
||||
"slot_to_init": null,
|
||||
"slot_to_mask": null,
|
||||
"snapshot_generate": false,
|
||||
"select_slot": null,
|
||||
"pack": null,
|
||||
"actions": ["generate"],
|
||||
"search_query": null,
|
||||
"notes": "one-line why"
|
||||
}
|
||||
```
|
||||
|
||||
### Patch rules
|
||||
|
||||
- Omit keys you are not changing.
|
||||
- `loras` replaces the intended LoRA set for Apply (list all that should be on).
|
||||
- Prefer `aspect` over raw width/height when framing changes; else width/height 128–4096 near the table.
|
||||
- `vary: true` — new random seed, keep prompt. `lock_seed: true` — reuse current seed (not −1).
|
||||
- `images` / `batch` — batch size.
|
||||
- `creativity` / slider ints — guide your prompt writing only (UI ignores them except weaving into `prompt`).
|
||||
- `clear_prompt_images: true` — strip image embeds from the prompt box.
|
||||
- `pack` — switch active prompt pack for a follow-up hop (`write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`).
|
||||
- Do not invent model or LoRA filenames.
|
||||
- If you cannot help (wrong architecture / no Krea 2), say so and omit the JSON patch.
|
||||
|
||||
### Init image / inpaint
|
||||
|
||||
- **img2img:** `use_init_image: true` + optional `slot_to_init` + `init_creativity` (0≈copy, 1≈new). Edits **0.25–0.45**; restyle **0.5–0.7**. Alias `denoise` OK.
|
||||
- **Inpaint:** Init + Mask. White = edit, black = keep. `slot_to_mask` when a board window is a mask. If no mask yet, tell user to paint one / **As Mask** — never invent pixels.
|
||||
- `clear_init_image` / `clear_mask_image` to leave img2img.
|
||||
- Prompt Images ≠ Init. Prefer Init for structure; Prompt Images for style (warn they dominate).
|
||||
|
||||
### Actions (auto-safe)
|
||||
|
||||
- `"generate"` — after Apply, start generation (UI auto-generate on by default).
|
||||
- `"use_init"` / `"use_mask"` — same as boolean flags.
|
||||
- `"search_civitai"` — Civitai search; user **Confirm**s downloads.
|
||||
- `"interrupt"` — stop generation.
|
||||
- `look_at: ["generate", "ref1"]` — vision hop for those board windows.
|
||||
- `slot_to_init` / `slot_to_mask` — copy board id into Swarm Init / Mask.
|
||||
- `snapshot_generate: true` — copy live Generate into a Ref.
|
||||
- Pure Q&A with no change: omit the JSON patch (do not burn GPU).
|
||||
|
||||
### Auto-apply note
|
||||
|
||||
The UI may auto-apply and auto-generate when `actions` contains `generate` or when you change prompt/loras/size/init. Keep patches intentional.
|
||||
@@ -1,27 +1,41 @@
|
||||
# Swarm Assistent
|
||||
|
||||
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, personas, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
|
||||
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
|
||||
|
||||
**Version 0.6.0** — board tabs, Cards form + live Civitai meta, Interrupt cancel, slim history, LoRA chips, Turbo/RAW, taste on disk, RU UI.
|
||||
**Version 0.7.0** — Config/_base + persona folders, skills, memory-seed → SQLite, Ollama `use: chat|memory`, slim inventory via retrieve.
|
||||
|
||||
## Layout
|
||||
|
||||
- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`)
|
||||
- **Splitter:** drag to resize panes
|
||||
- **Right:** Chat | Cards; persona / pack / Ollama model; settings gear
|
||||
- **Chips:** aspect, Seed lock/−1, Vary, Turbo/RAW; LoRA chip row under them
|
||||
- Slash autocomplete when the composer starts with `/`
|
||||
- **Right:** Chat | Cards; persona / pack / Ollama chat model; settings gear (memory model + skills)
|
||||
- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
|
||||
|
||||
## Config (bundled + overlay)
|
||||
|
||||
```
|
||||
Config/
|
||||
_base/ # defaults (assistant, ui, models/krea2, core, packs, skills, memory-seed, identity)
|
||||
personas/<id>/ # sparse preset: persona/voice/likes/dislikes/rules + optional overrides
|
||||
```
|
||||
|
||||
Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same layout, plus `settings.json`, `taste.json`, `personas.json` (legacy prompt overlay), `ollama-roles.json`, `memory/assistent.sqlite`.
|
||||
|
||||
Copy `personas/cinema/` → `noir/`, edit only differing JSON files.
|
||||
|
||||
## Vector memory
|
||||
|
||||
- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
|
||||
- First chat seeds `Config/_base/memory-seed/` (Krea facts, aspect, pitfalls)
|
||||
- Agents upsert via patch `memory_upsert` / `memory_forget`
|
||||
- Cards ingest on save; retrieve → `memory_hits` in live context (inventory slimmed)
|
||||
|
||||
## UX
|
||||
|
||||
- **Send to Assistent** under Generate/History → Ref + Assistent tab
|
||||
- Drop on Generate → new Ref + switch to Refs tab
|
||||
- **As Init** / **As Mask** / **Clear Init**
|
||||
- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch (no late auto-apply)
|
||||
- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
|
||||
- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
|
||||
- Auto-critique rewrites prompt only (no second Generate)
|
||||
- Civitai Confirm required (unless auto-download)
|
||||
- Cards: form fields + previews; **Load Civitai meta** fetches by SHA (`by-hash`); status always explicit
|
||||
|
||||
### Slash commands (client-side, no LLM)
|
||||
|
||||
@@ -35,15 +49,15 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
|
||||
| `/aspect 16:9` | Set size from the official 1K table |
|
||||
| `/seed lock\|random` | Lock or randomize seed |
|
||||
| `/vary` | New seed, same prompt (+ generate if auto) |
|
||||
| `/pack write\|critique\|compose\|params\|inpaint\|describe\|card` | Switch pack |
|
||||
| `/civitai <query>` | Ask LLM to search Civitai (Confirm still required) |
|
||||
| `/pack write\|critique\|…` | Switch pack |
|
||||
| `/civitai <query>` | Ask LLM to search Civitai |
|
||||
| `/inventory` | Rescan models + refresh LoRA list |
|
||||
|
||||
## Requirements
|
||||
|
||||
- SwarmUI with a **Krea 2** checkpoint selected
|
||||
- Ollama on `http://127.0.0.1:11434` **on the GPU VM** (gpu-rent `LLM_RUNTIME=ollama`)
|
||||
- At least one pulled model
|
||||
- Chat model + memory embed (`use: memory` in `ollama-models.yaml`; gpu-rent creates CPU variant)
|
||||
- Optional: Civitai API key in SwarmUI User Settings
|
||||
|
||||
## Install
|
||||
@@ -58,40 +72,30 @@ swarmui:
|
||||
|
||||
Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
|
||||
|
||||
## Prompt packs
|
||||
## Packs & skills
|
||||
|
||||
| Pack | Role |
|
||||
| --- | --- |
|
||||
| `base_krea2` | Always injected |
|
||||
| `write_prompt` | Craft / improve prompts |
|
||||
| `critique_image` | Vision critique |
|
||||
| `compose_scene` | Scene via board refs |
|
||||
| `fix_params` | Aspect / steps / CFG / seed (`krea_profile` in live context) |
|
||||
| `inpaint_edit` | Init + Mask |
|
||||
| `describe_ref` | Vision → prompt |
|
||||
| `catalog_card` | Recommendation card JSON |
|
||||
**Packs** (one active): `write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`.
|
||||
|
||||
**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`. Overlay: `/mnt/swarm_data/Assistent/personas.json` (gpu-rent seeds from `assistent-personas.yaml` → yaml + json). Assistent reads **json** only.
|
||||
**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures, not model encyclopedia (facts live in memory-seed).
|
||||
|
||||
**Taste:** `/mnt/swarm_data/Assistent/taste.json` via `AssistentGetTaste` / `AssistentSaveTaste` (merged with browser localStorage by `updated`).
|
||||
|
||||
**Cards:** `{stem}.assistent.json` next to weights; Civitai sidecar `{stem}.civitai.json`. Wanted queue → `.gpu-rent-wanted-models.yaml`.
|
||||
**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse` under `Config/personas/`.
|
||||
|
||||
## API routes
|
||||
|
||||
| Route | Role |
|
||||
| --- | --- |
|
||||
| `AssistentListModels` | Ollama `/api/tags` |
|
||||
| `AssistentListModels` | Ollama tags → `models` (chat) + `memory_models` |
|
||||
| `AssistentGetConfig` | Merged preset for persona (ui, packs, skills, identity) |
|
||||
| `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) |
|
||||
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
|
||||
| `AssistentListPersonas` | Bundled + overlay personas |
|
||||
| `AssistentListPersonas` | Persona catalog |
|
||||
| `AssistentGetPacks` | Prompt pack texts |
|
||||
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards |
|
||||
| `AssistentGetCardMeta` | Local sidecar + optional `fetch=true` Civitai by-hash |
|
||||
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
|
||||
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
|
||||
| `AssistentEnqueueWanted` | Wanted YAML queue |
|
||||
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
|
||||
| `AssistentSearchCivitai` | Civitai LoRA search |
|
||||
| `AssistentChat` | HTTP chat (+ Civitai hop) |
|
||||
| `AssistentChatWS` | Streaming chat WebSocket |
|
||||
| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+484
-227
@@ -30,27 +30,15 @@ public class SwarmAssistentExtension : Extension
|
||||
|
||||
public static HttpClient HttpClient;
|
||||
|
||||
public static readonly string[] PackNames =
|
||||
[
|
||||
"base_krea2",
|
||||
"write_prompt",
|
||||
"critique_image",
|
||||
"compose_scene",
|
||||
"fix_params",
|
||||
"inpaint_edit",
|
||||
"describe_ref",
|
||||
"catalog_card",
|
||||
];
|
||||
public AssistentConfig Config;
|
||||
public AssistentMemory Memory;
|
||||
|
||||
public static readonly string[] DefaultPersonaIds = ["neutral", "lewd", "aggressive", "cinema", "terse"];
|
||||
|
||||
const int MaxCivitaiHops = 2;
|
||||
const int MaxLorasInInventory = 150;
|
||||
const int MaxWildcardsInInventory = 80;
|
||||
const int MaxCheckpointsInInventory = 60;
|
||||
const int InventoryBlurbMax = 140;
|
||||
/// <summary>Ollama default num_ctx is 4096; Assistent system+inventory+vision exceeds that.</summary>
|
||||
const int DefaultNumCtx = 16384;
|
||||
const int MaxCivitaiHopsFallback = 2;
|
||||
const int MaxLorasInInventoryFallback = 150;
|
||||
const int MaxWildcardsInInventoryFallback = 80;
|
||||
const int MaxCheckpointsInInventoryFallback = 60;
|
||||
const int InventoryBlurbMaxFallback = 140;
|
||||
const int DefaultNumCtxFallback = 16384;
|
||||
|
||||
static readonly Regex JsonFenceRe = new(@"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
@@ -59,18 +47,23 @@ public class SwarmAssistentExtension : Extension
|
||||
ScriptFiles.Add("Assets/assistent.js");
|
||||
StyleSheetFiles.Add("Assets/assistent.css");
|
||||
ExtensionAuthor = "mrleo1nid";
|
||||
Description = "Collaborative Krea 2 assistant: Ollama chat, multi-window board, personas, model cards, Generate loop, Civitai Confirm.";
|
||||
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
||||
License = "MIT";
|
||||
Version = "0.6.0";
|
||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint"];
|
||||
Version = "0.7.0";
|
||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
|
||||
}
|
||||
|
||||
public override void OnInit()
|
||||
{
|
||||
HttpClient ??= new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
Config = new AssistentConfig(FilePath, DataRoot());
|
||||
Memory = new AssistentMemory(DataRoot(), HttpClient, Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text");
|
||||
API.RegisterAPICall(AssistentListModels, false, PermUse);
|
||||
API.RegisterAPICall(AssistentGetPacks, false, PermUse);
|
||||
API.RegisterAPICall(AssistentListPersonas, false, PermUse);
|
||||
API.RegisterAPICall(AssistentGetConfig, false, PermUse);
|
||||
API.RegisterAPICall(AssistentGetSettings, false, PermUse);
|
||||
API.RegisterAPICall(AssistentSaveSettings, true, PermUse);
|
||||
API.RegisterAPICall(AssistentListInventory, false, PermUse);
|
||||
API.RegisterAPICall(AssistentGetCard, false, PermUse);
|
||||
API.RegisterAPICall(AssistentSaveCard, true, PermUse);
|
||||
@@ -81,7 +74,19 @@ public class SwarmAssistentExtension : Extension
|
||||
API.RegisterAPICall(AssistentSaveTaste, true, PermUse);
|
||||
API.RegisterAPICall(AssistentChat, true, PermUse);
|
||||
API.RegisterAPICall(AssistentChatWS, true, PermUse);
|
||||
Logs.Init("Swarm Assistent extension loaded (Ollama proxy + personas + model cards)");
|
||||
Logs.Init("Swarm Assistent extension loaded (Config presets + vector memory)");
|
||||
}
|
||||
|
||||
int CfgInt(string key, int fallback)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Config?.LoadAssistant(Config.DefaultPersonaId())[key]?.Value<int?>() ?? fallback;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
static string Clip(string text, int max)
|
||||
@@ -105,17 +110,7 @@ public class SwarmAssistentExtension : Extension
|
||||
|
||||
public string ReadPackFile(string name)
|
||||
{
|
||||
string safe = name.Replace('\\', '/').AfterLast('/').Replace("..", "");
|
||||
if (!PackNames.Contains(safe))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string path = Path.Combine(FilePath, "Prompts", $"{safe}.md");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return File.ReadAllText(path, Encoding.UTF8);
|
||||
return Config?.LoadPackPrompt(Config.DefaultPersonaId(), name);
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentListModels(Session session, string baseUrl)
|
||||
@@ -130,12 +125,79 @@ public class SwarmAssistentExtension : Extension
|
||||
return new JObject { ["error"] = $"Ollama /api/tags HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
|
||||
}
|
||||
JObject parsed = JObject.Parse(body);
|
||||
JArray models = [];
|
||||
JArray all = [];
|
||||
foreach (JToken m in parsed["models"] as JArray ?? [])
|
||||
{
|
||||
models.Add(m["name"]?.ToString() ?? "");
|
||||
string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
all.Add(name);
|
||||
}
|
||||
}
|
||||
return new JObject { ["success"] = true, ["base_url"] = root, ["models"] = models };
|
||||
JObject roles = Config?.LoadOllamaRoles() ?? new JObject();
|
||||
HashSet<string> chatSet = new(
|
||||
(roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
HashSet<string> memSet = new(
|
||||
(roles["memory"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
// Heuristic fallbacks when sidecar missing
|
||||
if (chatSet.Count == 0 && memSet.Count == 0)
|
||||
{
|
||||
foreach (JToken t in all)
|
||||
{
|
||||
string n = t.ToString();
|
||||
if (LooksLikeEmbedModel(n))
|
||||
{
|
||||
memSet.Add(n);
|
||||
}
|
||||
else
|
||||
{
|
||||
chatSet.Add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep only tags that exist; anything unlabeled goes to chat if not memory
|
||||
foreach (JToken t in all)
|
||||
{
|
||||
string n = t.ToString();
|
||||
if (memSet.Contains(n) || LooksLikeEmbedModel(n))
|
||||
{
|
||||
memSet.Add(n);
|
||||
chatSet.Remove(n);
|
||||
}
|
||||
else if (chatSet.Count == 0 || chatSet.Contains(n))
|
||||
{
|
||||
chatSet.Add(n);
|
||||
}
|
||||
else if (!memSet.Contains(n))
|
||||
{
|
||||
chatSet.Add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
JArray models = new(all.Select(t => t.ToString()).Where(n => chatSet.Contains(n) && !memSet.Contains(n) && !LooksLikeEmbedModel(n)));
|
||||
JArray memoryModels = new(all.Select(t => t.ToString()).Where(n => memSet.Contains(n) || LooksLikeEmbedModel(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||||
if (memoryModels.Count == 0)
|
||||
{
|
||||
string fallback = Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text";
|
||||
if (all.Any(t => string.Equals(t.ToString(), fallback, StringComparison.OrdinalIgnoreCase)
|
||||
|| t.ToString().StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
memoryModels.Add(all.Select(t => t.ToString()).First(n =>
|
||||
string.Equals(n, fallback, StringComparison.OrdinalIgnoreCase)
|
||||
|| n.StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["base_url"] = root,
|
||||
["models"] = models,
|
||||
["memory_models"] = memoryModels,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -143,18 +205,65 @@ public class SwarmAssistentExtension : Extension
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetPacks(Session session)
|
||||
static bool LooksLikeEmbedModel(string name)
|
||||
{
|
||||
string n = (name ?? "").ToLowerInvariant();
|
||||
return n.Contains("embed") || n.Contains("nomic") || n.Contains("bge-") || n.Contains("minilm") || n.Contains("e5-");
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetPacks(Session session, string persona = null)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
|
||||
JObject packs = new();
|
||||
foreach (string name in PackNames)
|
||||
JArray order = [];
|
||||
foreach (var p in Config.ListPacks(pid))
|
||||
{
|
||||
string text = ReadPackFile(name);
|
||||
string text = Config.LoadPackPrompt(pid, p.id);
|
||||
if (text is not null)
|
||||
{
|
||||
packs[name] = text;
|
||||
packs[p.id] = text;
|
||||
}
|
||||
order.Add(p.id);
|
||||
}
|
||||
return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = order, ["persona"] = pid };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetConfig(Session session, string persona = null)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
|
||||
return Config.BuildMergedConfigPayload(pid);
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetSettings(Session session)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new JObject { ["success"] = true, ["settings"] = Config.LoadSettings() };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentSaveSettings(Session session, JObject settings)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
if (settings is null)
|
||||
{
|
||||
return new JObject { ["error"] = "settings required" };
|
||||
}
|
||||
string prevEmbed = Config.LoadSettings()["embed_model"]?.ToString();
|
||||
Config.SaveSettings(settings);
|
||||
string nextEmbed = settings["embed_model"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(nextEmbed) && !string.Equals(prevEmbed, nextEmbed, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
await Memory.ReembedAllAsync(NormalizeBaseUrl(settings["base_url"]?.ToString()), nextEmbed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentSaveSettings reembed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
return new JObject { ["success"] = true, ["packs"] = packs, ["order"] = new JArray(PackNames) };
|
||||
return new JObject { ["success"] = true, ["path"] = Path.Combine(Config.OverlayRoot, "settings.json") };
|
||||
}
|
||||
|
||||
static string DataRoot()
|
||||
@@ -184,110 +293,26 @@ public class SwarmAssistentExtension : Extension
|
||||
|
||||
string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards");
|
||||
|
||||
public string ReadPersonaFile(string id)
|
||||
{
|
||||
string safe = (id ?? "").Replace('\\', '/').AfterLast('/').Replace("..", "");
|
||||
if (string.IsNullOrWhiteSpace(safe))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string path = Path.Combine(FilePath, "Personas", $"{safe}.md");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return File.ReadAllText(path, Encoding.UTF8);
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentListPersonas(Session session)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
Dictionary<string, JObject> byId = new(StringComparer.OrdinalIgnoreCase);
|
||||
string def = "neutral";
|
||||
|
||||
foreach (string id in DefaultPersonaIds)
|
||||
{
|
||||
string text = ReadPersonaFile(id);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
byId[id] = new JObject
|
||||
{
|
||||
["id"] = id,
|
||||
["title"] = id switch
|
||||
{
|
||||
"lewd" => "Пошляк",
|
||||
"aggressive" => "Агрессивный",
|
||||
"cinema" => "Кинооператор",
|
||||
"terse" => "Короткий",
|
||||
_ => "Нейтральный",
|
||||
},
|
||||
["prompt"] = text,
|
||||
["source"] = "bundled",
|
||||
};
|
||||
}
|
||||
|
||||
string overlay = PersonasOverlayJsonPath();
|
||||
if (File.Exists(overlay))
|
||||
{
|
||||
try
|
||||
{
|
||||
JObject parsed = JObject.Parse(File.ReadAllText(overlay, Encoding.UTF8));
|
||||
if (parsed["default"] != null)
|
||||
{
|
||||
def = parsed["default"]?.ToString() ?? def;
|
||||
}
|
||||
if (parsed["personas"] is JArray arr)
|
||||
{
|
||||
foreach (JToken t in arr)
|
||||
{
|
||||
if (t is not JObject po)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string id = (po["id"]?.ToString() ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string overlayPrompt = po["prompt"]?.ToString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(overlayPrompt) && byId.ContainsKey(id))
|
||||
{
|
||||
// Keep bundled prompt when overlay prompt is empty.
|
||||
byId[id]["title"] = po["title"]?.ToString() ?? byId[id]["title"];
|
||||
byId[id]["source"] = "overlay+bundled";
|
||||
continue;
|
||||
}
|
||||
byId[id] = new JObject
|
||||
{
|
||||
["id"] = id,
|
||||
["title"] = po["title"]?.ToString() ?? id,
|
||||
["prompt"] = overlayPrompt,
|
||||
["source"] = "overlay",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentListPersonas overlay: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var catalog = Config.ListPersonaCatalog();
|
||||
JArray list = [];
|
||||
foreach (JObject p in byId.Values.OrderBy(p => p["id"]?.ToString()))
|
||||
foreach (var p in catalog)
|
||||
{
|
||||
list.Add(p);
|
||||
}
|
||||
if (!byId.ContainsKey(def) && list.Count > 0)
|
||||
{
|
||||
def = list[0]?["id"]?.ToString() ?? "neutral";
|
||||
list.Add(new JObject
|
||||
{
|
||||
["id"] = p.id,
|
||||
["title"] = p.title,
|
||||
["accent"] = p.accent,
|
||||
["prompt"] = Config.RenderIdentityBlock(p.id),
|
||||
["source"] = p.source,
|
||||
});
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["default"] = def,
|
||||
["default"] = Config.DefaultPersonaId(),
|
||||
["personas"] = list,
|
||||
};
|
||||
}
|
||||
@@ -408,6 +433,7 @@ public class SwarmAssistentExtension : Extension
|
||||
{
|
||||
string path = CardPathForWeight(weight);
|
||||
File.WriteAllText(path, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
_ = IngestCardToMemory(card, name);
|
||||
return new JObject { ["success"] = true, ["path"] = path, ["installed"] = true };
|
||||
}
|
||||
|
||||
@@ -421,9 +447,53 @@ public class SwarmAssistentExtension : Extension
|
||||
{
|
||||
await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value<int?>() ?? 0, card["title"]?.ToString() ?? name, card);
|
||||
}
|
||||
_ = IngestCardToMemory(card, name);
|
||||
return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
|
||||
}
|
||||
|
||||
async Task IngestCardToMemory(JObject card, string name)
|
||||
{
|
||||
if (Memory is null || card is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
string kind = (card["kind"]?.ToString() ?? "lora").Trim().ToLowerInvariant();
|
||||
string key = (card["name"]?.ToString() ?? name ?? "").Trim();
|
||||
List<string> bits = [];
|
||||
foreach (string field in new[] { "when", "avoid", "prompt_hint", "notes" })
|
||||
{
|
||||
string v = card[field]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(v))
|
||||
{
|
||||
bits.Add($"{field}: {v.Trim()}");
|
||||
}
|
||||
}
|
||||
if (card["triggers"] is JArray tr)
|
||||
{
|
||||
string joined = string.Join(", ", tr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
if (!string.IsNullOrWhiteSpace(joined))
|
||||
{
|
||||
bits.Add("triggers: " + joined);
|
||||
}
|
||||
}
|
||||
if (bits.Count == 0 || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
string text = $"{kind} {key}. " + string.Join(" ", bits);
|
||||
string baseUrl = NormalizeBaseUrl(Config.LoadSettings()["base_url"]?.ToString());
|
||||
string embedModel = Config.LoadSettings()["embed_model"]?.ToString()
|
||||
?? Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString();
|
||||
await Memory.UpsertTextAsync(baseUrl, "card", key, text, "user", card, embedModel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"IngestCardToMemory: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentEnqueueWanted(Session session, string kind, string url, int version_id = 0, string title = null, JObject card = null)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
@@ -923,7 +993,7 @@ public class SwarmAssistentExtension : Extension
|
||||
foreach (T2IModel model in loraHandler.Models.Values
|
||||
.OrderByDescending(m => LooksLikeKreaArch(m))
|
||||
.ThenBy(m => m.Name)
|
||||
.Take(MaxLorasInInventory))
|
||||
.Take(CfgInt("max_loras_inventory", MaxLorasInInventoryFallback)))
|
||||
{
|
||||
loras.Add(BuildInventoryModelEntry(model, "lora"));
|
||||
}
|
||||
@@ -934,7 +1004,7 @@ public class SwarmAssistentExtension : Extension
|
||||
foreach (T2IModel model in ckptHandler.Models.Values
|
||||
.OrderByDescending(m => LooksLikeKreaArch(m))
|
||||
.ThenBy(m => m.Name)
|
||||
.Take(MaxCheckpointsInInventory))
|
||||
.Take(CfgInt("max_checkpoints_inventory", MaxCheckpointsInInventoryFallback)))
|
||||
{
|
||||
checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint"));
|
||||
}
|
||||
@@ -942,7 +1012,7 @@ public class SwarmAssistentExtension : Extension
|
||||
|
||||
try
|
||||
{
|
||||
foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(MaxWildcardsInInventory))
|
||||
foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(CfgInt("max_wildcards_inventory", MaxWildcardsInInventoryFallback)))
|
||||
{
|
||||
wildcards.Add(new JObject { ["name"] = name });
|
||||
}
|
||||
@@ -1005,7 +1075,7 @@ public class SwarmAssistentExtension : Extension
|
||||
string fromCard = (card["notes"] ?? card["when"] ?? card["prompt_hint"])?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(fromCard))
|
||||
{
|
||||
blurb = Clip(fromCard.Trim(), InventoryBlurbMax);
|
||||
blurb = Clip(fromCard.Trim(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -1018,7 +1088,7 @@ public class SwarmAssistentExtension : Extension
|
||||
string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
blurb = Clip(CollapseWs(raw), InventoryBlurbMax);
|
||||
blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1262,28 +1332,42 @@ public class SwarmAssistentExtension : Extension
|
||||
};
|
||||
}
|
||||
|
||||
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null)
|
||||
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
||||
{
|
||||
List<JObject> ollamaMessages = [];
|
||||
StringBuilder system = new();
|
||||
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
|
||||
|
||||
if (includeBase)
|
||||
{
|
||||
string basePack = ReadPackFile("base_krea2");
|
||||
if (!string.IsNullOrWhiteSpace(basePack))
|
||||
string core = Config.LoadCorePrompt(pid);
|
||||
if (!string.IsNullOrWhiteSpace(core))
|
||||
{
|
||||
system.AppendLine(basePack);
|
||||
system.AppendLine(core);
|
||||
}
|
||||
}
|
||||
string personaPrompt = ResolvePersonaPrompt(personaId);
|
||||
if (!string.IsNullOrWhiteSpace(personaPrompt))
|
||||
|
||||
foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
|
||||
{
|
||||
string skillText = Config.LoadSkillPrompt(pid, skillId);
|
||||
if (!string.IsNullOrWhiteSpace(skillText))
|
||||
{
|
||||
system.AppendLine();
|
||||
system.AppendLine($"## Skill: {skillId}");
|
||||
system.AppendLine(skillText);
|
||||
}
|
||||
}
|
||||
|
||||
string identity = Config.RenderIdentityBlock(pid);
|
||||
if (!string.IsNullOrWhiteSpace(identity))
|
||||
{
|
||||
system.AppendLine();
|
||||
system.AppendLine($"## Persona: {personaId ?? "neutral"}");
|
||||
system.AppendLine(personaPrompt);
|
||||
system.AppendLine(identity);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2")
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(packName) && packName != "base_krea2" && packName != "core")
|
||||
{
|
||||
string situational = ReadPackFile(packName);
|
||||
string situational = Config.LoadPackPrompt(pid, packName);
|
||||
if (!string.IsNullOrWhiteSpace(situational))
|
||||
{
|
||||
system.AppendLine();
|
||||
@@ -1332,41 +1416,7 @@ public class SwarmAssistentExtension : Extension
|
||||
return ollamaMessages;
|
||||
}
|
||||
|
||||
string ResolvePersonaPrompt(string personaId)
|
||||
{
|
||||
string id = (personaId ?? "neutral").Trim();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = "neutral";
|
||||
}
|
||||
string overlay = PersonasOverlayJsonPath();
|
||||
if (File.Exists(overlay))
|
||||
{
|
||||
try
|
||||
{
|
||||
JObject parsed = JObject.Parse(File.ReadAllText(overlay, Encoding.UTF8));
|
||||
if (parsed["personas"] is JArray arr)
|
||||
{
|
||||
foreach (JToken t in arr)
|
||||
{
|
||||
if (t is JObject po && string.Equals(po["id"]?.ToString(), id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string p = po["prompt"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(p))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to bundled
|
||||
}
|
||||
}
|
||||
return ReadPersonaFile(id);
|
||||
}
|
||||
string ResolvePersonaPrompt(string personaId) => Config.RenderIdentityBlock(personaId);
|
||||
|
||||
static JObject TryParsePatch(string reply)
|
||||
{
|
||||
@@ -1396,7 +1446,7 @@ public class SwarmAssistentExtension : Extension
|
||||
|| obj["creativity"] != null || obj["intensity"] != null
|
||||
|| obj["complexity"] != null || obj["movement"] != null
|
||||
|| obj["clear_prompt_images"] != null || obj["slot_to_prompt_image"] != null
|
||||
|| obj["pack"] != null))
|
||||
|| obj["pack"] != null || obj["memories"] != null || obj["memory"] != null))
|
||||
{
|
||||
return obj;
|
||||
}
|
||||
@@ -1442,6 +1492,35 @@ public class SwarmAssistentExtension : Extension
|
||||
return !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch));
|
||||
}
|
||||
|
||||
static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills)
|
||||
{
|
||||
JObject whole = raw ?? [];
|
||||
JObject nested = whole["raw"] as JObject;
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = whole["base_url"]?.ToString()
|
||||
?? whole["baseUrl"]?.ToString()
|
||||
?? nested?["base_url"]?.ToString()
|
||||
?? nested?["baseUrl"]?.ToString();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
{
|
||||
model = whole["model"]?.ToString() ?? nested?["model"]?.ToString();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(pack))
|
||||
{
|
||||
pack = whole["pack"]?.ToString() ?? nested?["pack"]?.ToString();
|
||||
}
|
||||
if (whole["includeBase"] is not null)
|
||||
{
|
||||
includeBase = whole.Value<bool?>("includeBase") ?? includeBase;
|
||||
}
|
||||
userMessages = (whole["messages"] as JArray) ?? (nested?["messages"] as JArray);
|
||||
contextJson = whole["context_json"]?.ToString() ?? nested?["context_json"]?.ToString();
|
||||
persona = whole["persona"]?.ToString() ?? nested?["persona"]?.ToString() ?? "neutral";
|
||||
skills = (whole["skills"] as JArray) ?? (nested?["skills"] as JArray);
|
||||
}
|
||||
|
||||
async Task<(string reply, JObject raw, JArray civitaiResults)> RunChatWithHops(
|
||||
Session session,
|
||||
string root,
|
||||
@@ -1452,21 +1531,55 @@ public class SwarmAssistentExtension : Extension
|
||||
JArray userMessages,
|
||||
Func<string, Task> onDelta = null,
|
||||
Func<int, Task> onHopStart = null,
|
||||
string personaId = null)
|
||||
string personaId = null,
|
||||
JArray skillIds = null,
|
||||
string embedModel = null)
|
||||
{
|
||||
List<JObject> messages = BuildOllamaMessages(packName, includeBase, contextJson, userMessages, personaId: personaId);
|
||||
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
|
||||
List<string> skills = Config.ResolveEnabledSkills(pid, skillIds);
|
||||
string embed = string.IsNullOrWhiteSpace(embedModel)
|
||||
? (Config.LoadSettings()["embed_model"]?.ToString()
|
||||
?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
|
||||
?? "nomic-embed-text")
|
||||
: embedModel;
|
||||
|
||||
try
|
||||
{
|
||||
await Memory.EnsureSeedAsync(root, Config, embed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"Assistent memory seed: {ex.Message}");
|
||||
}
|
||||
|
||||
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson);
|
||||
JArray hits = [];
|
||||
try
|
||||
{
|
||||
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 10;
|
||||
hits = await Memory.RetrieveAsync(root, retrieveQuery, topK, embed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"Assistent memory retrieve: {ex.Message}");
|
||||
}
|
||||
|
||||
string enrichedContext = InjectMemoryHits(contextJson, hits);
|
||||
List<JObject> messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
|
||||
JArray civitaiResults = [];
|
||||
string reply = "";
|
||||
JObject lastRaw = null;
|
||||
for (int hop = 0; hop < MaxCivitaiHops; hop++)
|
||||
int maxHops = CfgInt("max_civitai_hops", MaxCivitaiHopsFallback);
|
||||
for (int hop = 0; hop < maxHops; hop++)
|
||||
{
|
||||
if (onHopStart is not null)
|
||||
{
|
||||
await onHopStart(hop);
|
||||
}
|
||||
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta);
|
||||
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
||||
JObject patch = TryParsePatch(reply);
|
||||
if (hop + 1 >= MaxCivitaiHops || !WantsCivitaiSearch(patch))
|
||||
await ApplyMemoryActions(root, patch, embed);
|
||||
if (hop + 1 >= maxHops || !WantsCivitaiSearch(patch))
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -1501,13 +1614,180 @@ public class SwarmAssistentExtension : Extension
|
||||
return (reply, lastRaw, civitaiResults);
|
||||
}
|
||||
|
||||
static string BuildRetrieveQuery(JArray userMessages, string contextJson)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
if (!string.IsNullOrWhiteSpace(contextJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
JObject ctx = JObject.Parse(contextJson);
|
||||
string ckpt = ctx["checkpoint"]?.ToString() ?? ctx["current_model"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(ckpt))
|
||||
{
|
||||
sb.Append(ckpt).Append(' ');
|
||||
}
|
||||
if (ctx["enabled_loras"] is JArray en)
|
||||
{
|
||||
foreach (JToken t in en.Take(8))
|
||||
{
|
||||
string n = t?["name"]?.ToString() ?? t?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(n))
|
||||
{
|
||||
sb.Append(n).Append(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ctx["krea_profile"] != null)
|
||||
{
|
||||
sb.Append("krea ").Append(ctx["krea_profile"]).Append(' ');
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
foreach (JToken msg in (userMessages ?? []).Reverse().Take(2))
|
||||
{
|
||||
if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
sb.Append(mo["content"]?.ToString()).Append(' ');
|
||||
}
|
||||
}
|
||||
string q = CollapseWs(sb.ToString());
|
||||
return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q;
|
||||
}
|
||||
|
||||
static string InjectMemoryHits(string contextJson, JArray hits)
|
||||
{
|
||||
JObject ctx;
|
||||
try
|
||||
{
|
||||
ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ctx = new JObject { ["_raw_context"] = contextJson };
|
||||
}
|
||||
ctx["memory_hits"] = hits ?? new JArray();
|
||||
// Slim inventory for LLM: keep enabled + current, drop full dump if present
|
||||
if (ctx["available_loras"] is JArray allLoras && allLoras.Count > 24)
|
||||
{
|
||||
HashSet<string> keep = new(StringComparer.OrdinalIgnoreCase);
|
||||
if (ctx["enabled_loras"] is JArray en)
|
||||
{
|
||||
foreach (JToken t in en)
|
||||
{
|
||||
string n = t?["name"]?.ToString() ?? t?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(n))
|
||||
{
|
||||
keep.Add(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (JToken hit in hits ?? [])
|
||||
{
|
||||
if (string.Equals(hit?["kind"]?.ToString(), "lora", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(hit?["kind"]?.ToString(), "card", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string k = hit?["key"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(k))
|
||||
{
|
||||
keep.Add(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
JArray slim = [];
|
||||
foreach (JToken t in allLoras)
|
||||
{
|
||||
string n = t?["name"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(n) && (keep.Contains(n) || slim.Count < 12))
|
||||
{
|
||||
if (keep.Contains(n) || t?["krea_likely"]?.Value<bool>() == true)
|
||||
{
|
||||
slim.Add(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (slim.Count == 0)
|
||||
{
|
||||
foreach (JToken t in allLoras.Take(12))
|
||||
{
|
||||
slim.Add(t);
|
||||
}
|
||||
}
|
||||
ctx["available_loras"] = slim;
|
||||
ctx["available_loras_truncated"] = true;
|
||||
ctx["available_loras_total"] = allLoras.Count;
|
||||
}
|
||||
return ctx.ToString(Newtonsoft.Json.Formatting.None);
|
||||
}
|
||||
|
||||
async Task ApplyMemoryActions(string root, JObject patch, string embedModel)
|
||||
{
|
||||
if (patch is null || Memory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
bool upsert = false, forget = false;
|
||||
if (patch["actions"] is JArray acts)
|
||||
{
|
||||
foreach (JToken a in acts)
|
||||
{
|
||||
string s = a?.ToString() ?? "";
|
||||
if (string.Equals(s, "memory_upsert", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
upsert = true;
|
||||
}
|
||||
if (string.Equals(s, "memory_forget", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
forget = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
JArray memories = patch["memories"] as JArray;
|
||||
if (memories is null || memories.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (JToken t in memories)
|
||||
{
|
||||
if (t is not JObject mo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string kind = mo["kind"]?.ToString() ?? "note";
|
||||
string key = mo["key"]?.ToString() ?? "";
|
||||
string text = mo["text"]?.ToString() ?? "";
|
||||
try
|
||||
{
|
||||
if (forget && string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
Memory.Forget(kind, key);
|
||||
}
|
||||
else if (upsert || !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
await Memory.UpsertTextAsync(root, kind, key, text, "user", mo, embedModel);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"ApplyMemoryActions: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task<(string reply, JObject raw)> CallOllamaChat(
|
||||
string root,
|
||||
string modelName,
|
||||
List<JObject> ollamaMessages,
|
||||
bool stream,
|
||||
Func<string, Task> onDelta)
|
||||
Func<string, Task> onDelta,
|
||||
string personaId = null)
|
||||
{
|
||||
int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value<int?>()
|
||||
?? DefaultNumCtxFallback;
|
||||
JObject payload = new()
|
||||
{
|
||||
["model"] = modelName,
|
||||
@@ -1515,8 +1795,9 @@ public class SwarmAssistentExtension : Extension
|
||||
["messages"] = new JArray(ollamaMessages),
|
||||
["options"] = new JObject
|
||||
{
|
||||
["num_ctx"] = DefaultNumCtx,
|
||||
["num_ctx"] = numCtx,
|
||||
},
|
||||
["keep_alive"] = "15m",
|
||||
};
|
||||
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||
using HttpRequestMessage req = new(HttpMethod.Post, $"{root}/api/chat") { Content = content };
|
||||
@@ -1573,38 +1854,12 @@ public class SwarmAssistentExtension : Extension
|
||||
/// SwarmUI passes the whole request as the JObject param (not only a nested key).
|
||||
/// Support both flat fields and legacy nested <c>raw</c>.
|
||||
/// </summary>
|
||||
static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona)
|
||||
{
|
||||
JObject whole = raw ?? [];
|
||||
JObject nested = whole["raw"] as JObject;
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
baseUrl = whole["base_url"]?.ToString()
|
||||
?? whole["baseUrl"]?.ToString()
|
||||
?? nested?["base_url"]?.ToString()
|
||||
?? nested?["baseUrl"]?.ToString();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
{
|
||||
model = whole["model"]?.ToString() ?? nested?["model"]?.ToString();
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(pack))
|
||||
{
|
||||
pack = whole["pack"]?.ToString() ?? nested?["pack"]?.ToString();
|
||||
}
|
||||
if (whole["includeBase"] is not null)
|
||||
{
|
||||
includeBase = whole.Value<bool?>("includeBase") ?? includeBase;
|
||||
}
|
||||
userMessages = (whole["messages"] as JArray) ?? (nested?["messages"] as JArray);
|
||||
contextJson = whole["context_json"]?.ToString() ?? nested?["context_json"]?.ToString();
|
||||
persona = whole["persona"]?.ToString() ?? nested?["persona"]?.ToString() ?? "neutral";
|
||||
}
|
||||
// ExtractChatPayload defined above
|
||||
|
||||
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
|
||||
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
||||
{
|
||||
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona);
|
||||
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
||||
string root = NormalizeBaseUrl(baseUrl);
|
||||
string modelName = (model ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
@@ -1616,10 +1871,11 @@ public class SwarmAssistentExtension : Extension
|
||||
return new JObject { ["error"] = "messages required" };
|
||||
}
|
||||
string packName = (pack ?? "write_prompt").Trim();
|
||||
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
|
||||
try
|
||||
{
|
||||
(string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
|
||||
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona);
|
||||
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
@@ -1640,7 +1896,7 @@ public class SwarmAssistentExtension : Extension
|
||||
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
|
||||
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
||||
{
|
||||
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona);
|
||||
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
||||
string root = NormalizeBaseUrl(baseUrl);
|
||||
string modelName = (model ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(modelName))
|
||||
@@ -1654,6 +1910,7 @@ public class SwarmAssistentExtension : Extension
|
||||
return null;
|
||||
}
|
||||
string packName = (pack ?? "write_prompt").Trim();
|
||||
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
|
||||
try
|
||||
{
|
||||
if (ws.State == WebSocketState.Open)
|
||||
@@ -1684,7 +1941,7 @@ public class SwarmAssistentExtension : Extension
|
||||
}
|
||||
}
|
||||
(string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
|
||||
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona);
|
||||
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
|
||||
await ws.SendJson(new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
<PropertyGroup>
|
||||
<AssemblyName>SwarmAssistentExtension</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.11" />
|
||||
</ItemGroup>
|
||||
<Import Project="../../SwarmUI.extension.props" />
|
||||
</Project>
|
||||
|
||||
@@ -41,14 +41,8 @@
|
||||
</select>
|
||||
<select id="sa_pack" class="sa-select" title="Пакет промпта">
|
||||
<option value="write_prompt">Написать промпт</option>
|
||||
<option value="critique_image">Критика кадра</option>
|
||||
<option value="compose_scene">Собрать сцену</option>
|
||||
<option value="fix_params">Параметры</option>
|
||||
<option value="inpaint_edit">Inpaint / img2img</option>
|
||||
<option value="describe_ref">Описать ref</option>
|
||||
<option value="catalog_card">Карточка модели</option>
|
||||
</select>
|
||||
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama">
|
||||
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama (чат)">
|
||||
<option value="">Загрузка моделей…</option>
|
||||
</select>
|
||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_settings" title="Настройки" aria-label="Настройки">⚙</button>
|
||||
@@ -56,8 +50,15 @@
|
||||
</header>
|
||||
<div class="sa-settings" id="sa_settings" hidden>
|
||||
<label>Ollama URL <input type="text" id="sa_base_url" value="http://127.0.0.1:11434" /></label>
|
||||
<label>Модель памяти
|
||||
<select id="sa_embed_model" class="sa-select" title="Хранение и группировка памяти (use: memory)">
|
||||
<option value="nomic-embed-text">nomic-embed-text</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button>
|
||||
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
|
||||
<div class="sa-skills-label">Скилы (процедуры)</div>
|
||||
<div class="sa-skills-box" id="sa_skills_box"></div>
|
||||
<label class="sa-check"><input type="checkbox" id="sa_auto_vision" /> Авто-прикреплять Generate к чату</label>
|
||||
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
|
||||
<label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
|
||||
|
||||
Reference in New Issue
Block a user