diff --git a/Assets/assistent.css b/Assets/assistent.css
index a110bb6..2c23156 100644
--- a/Assets/assistent.css
+++ b/Assets/assistent.css
@@ -729,11 +729,172 @@
}
.sa-settings {
- display: grid;
+ display: flex;
+ flex-direction: column;
gap: 0.45rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 5%, transparent);
+ max-height: 50vh;
+}
+
+.sa-settings-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.sa-settings-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+}
+
+.sa-stab {
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ background: transparent;
+ color: inherit;
+ border-radius: 0.4rem;
+ padding: 0.2rem 0.55rem;
+ font-size: 0.78rem;
+ cursor: pointer;
+ opacity: 0.75;
+}
+
+.sa-stab:hover {
+ opacity: 1;
+}
+
+.sa-stab-active {
+ opacity: 1;
+ background: color-mix(in srgb, currentColor 12%, transparent);
+ border-color: color-mix(in srgb, currentColor 35%, transparent);
+}
+
+.sa-settings-panes {
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.sa-spane {
+ display: grid;
+ gap: 0.45rem;
+}
+
+.sa-settings-hint {
+ margin: 0;
+ font-size: 0.78rem;
+ opacity: 0.7;
+ line-height: 1.35;
+}
+
+.sa-settings-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+.sa-settings-row.sa-knob-row label {
+ flex: 1;
+ min-width: 5rem;
+}
+
+.sa-settings-health {
+ font-size: 0.75rem;
+ opacity: 0.8;
+}
+
+.sa-danger-btn {
+ color: #e06c75 !important;
+}
+
+.sa-persona-panel {
+ display: grid;
+ grid-template-columns: minmax(8rem, 11rem) 1fr;
+ gap: 0.5rem;
+ min-height: 10rem;
+}
+
+.sa-persona-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ max-height: 14rem;
+ overflow: auto;
+ border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
+ border-radius: 0.4rem;
+ padding: 0.25rem;
+}
+
+.sa-persona-item {
+ text-align: left;
+ border: none;
+ background: transparent;
+ color: inherit;
+ padding: 0.35rem 0.4rem;
+ border-radius: 0.3rem;
+ cursor: pointer;
+ font-size: 0.82rem;
+}
+
+.sa-persona-item:hover,
+.sa-persona-item-active {
+ background: color-mix(in srgb, currentColor 10%, transparent);
+}
+
+.sa-persona-item-title {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+}
+
+.sa-persona-dot {
+ width: 0.45rem;
+ height: 0.45rem;
+ border-radius: 50%;
+ background: currentColor;
+ flex: 0 0 auto;
+}
+
+.sa-persona-badge {
+ font-size: 0.62rem;
+ opacity: 0.55;
+ text-transform: uppercase;
+}
+
+.sa-persona-preview {
+ border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
+ border-radius: 0.4rem;
+ padding: 0.45rem 0.55rem;
+ max-height: 14rem;
+ overflow: auto;
+ font-size: 0.8rem;
+ line-height: 1.35;
+ white-space: pre-wrap;
+}
+
+.sa-user-prefs-cols {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.5rem;
+}
+
+@media (max-width: 720px) {
+ .sa-user-prefs-cols,
+ .sa-persona-panel {
+ grid-template-columns: 1fr;
+ }
+}
+
+.sa-mem-search {
+ flex: 1;
+ min-width: 6rem;
+ max-width: 12rem;
+ box-sizing: border-box;
+ font-size: 0.8rem;
}
.sa-settings label {
@@ -744,6 +905,9 @@
}
.sa-settings input[type="text"],
+.sa-settings input[type="number"],
+.sa-settings input[type="search"],
+.sa-settings input[type="range"],
.sa-select {
width: 100%;
box-sizing: border-box;
diff --git a/Assets/assistent.js b/Assets/assistent.js
index 8dd9d0b..d96cccd 100644
--- a/Assets/assistent.js
+++ b/Assets/assistent.js
@@ -160,6 +160,9 @@
slashIndex: 0,
llmParked: false,
memoryRows: [],
+ userPrefs: [],
+ settingsTab: 'behavior',
+ settingsPersonaId: null,
wanted: { count: 0, items: [] },
wantedKeys: new Set(),
ollamaHealth: 'unknown',
@@ -2152,6 +2155,18 @@
fillEmptyParamsFromExact();
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
+ const settings = $('sa_settings');
+ if (settings && !settings.hidden) {
+ if (state.settingsTab === 'user') {
+ refreshUserPrefs();
+ }
+ if (state.settingsTab === 'craft') {
+ renderMemoryList();
+ }
+ if (state.settingsTab === 'more') {
+ fillKnobsFromConfig(data);
+ }
+ }
});
}
@@ -2371,7 +2386,7 @@
auto_generate: !!$('sa_auto_generate')?.checked,
persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral',
model_cards: [],
- taste_profile: summarizeTaste(),
+ user_prefs_count: 0,
...initCtx,
};
@@ -2696,6 +2711,34 @@
taste.updated = Date.now();
state.taste = taste;
saveTaste();
+ syncTasteHintsToUserPrefs(taste);
+ }
+
+ function syncTasteHintsToUserPrefs(taste) {
+ if (typeof genericRequest !== 'function' || !taste) {
+ return;
+ }
+ const upsert = (key, text) => {
+ if (!text) {
+ return;
+ }
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ { key, text: String(text).slice(0, 240), scope: 'global', source: 'migrated_taste', pinned: false },
+ () => {},
+ 0,
+ () => {},
+ );
+ };
+ if (taste.avoid?.[0]) {
+ upsert('avoid_hint', `Avoid: ${taste.avoid.slice(0, 4).join('; ')}`);
+ }
+ if (taste.styles?.[0]) {
+ upsert('style_hint', `Styles: ${taste.styles.slice(0, 4).join('; ')}`);
+ }
+ if (taste.likes?.[0]) {
+ upsert('like_hint', `Often uses: ${taste.likes.slice(0, 4).join('; ')}`);
+ }
}
// Fallback key list — only used if assistent.patch.js failed to load.
@@ -2703,6 +2746,7 @@
'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise',
'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack',
+ 'memories', 'user_prefs',
];
function isPatchObject(obj) {
@@ -4265,6 +4309,7 @@
if (asst.context_prompt_max != null) {
CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
}
+ fillKnobsFromConfig(data);
if (applyDefaults || data.exact) {
fillEmptyParamsFromExact();
}
@@ -4537,37 +4582,42 @@
function setModelOptions(models, { error } = {}) {
const sel = $('sa_model');
- if (!sel) {
- return;
- }
- const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
- sel.innerHTML = '';
- if (error) {
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
- sel.appendChild(opt);
- sel.disabled = true;
- return;
- }
- sel.disabled = false;
- if (!names.length) {
- const opt = document.createElement('option');
- opt.value = '';
- opt.textContent = 'No Ollama models — pull / Refresh';
- 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.preferredModel || localStorage.getItem(LS_MODEL);
- if (prefer && names.includes(prefer)) {
- sel.value = prefer;
- }
+ const sel2 = $('sa_settings_chat_model');
+ const apply = (target) => {
+ if (!target) {
+ return;
+ }
+ const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
+ target.innerHTML = '';
+ if (error) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = `⚠ ${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
+ target.appendChild(opt);
+ target.disabled = true;
+ return;
+ }
+ target.disabled = false;
+ if (!names.length) {
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = 'No Ollama models — pull / Refresh';
+ target.appendChild(opt);
+ return;
+ }
+ for (const name of names) {
+ const opt = document.createElement('option');
+ opt.value = name;
+ opt.textContent = name;
+ target.appendChild(opt);
+ }
+ const prefer = state.preferredModel || localStorage.getItem(LS_MODEL);
+ if (prefer && names.includes(prefer)) {
+ target.value = prefer;
+ }
+ };
+ apply(sel);
+ apply(sel2);
}
function setEmbedModelOptions(models) {
@@ -4691,6 +4741,14 @@
return $('sa_mem_kind')?.value || 'all';
}
+ function memoryScopeFilter() {
+ return $('sa_mem_scope')?.value || 'all';
+ }
+
+ function memorySearchFilter() {
+ return ($('sa_mem_search')?.value || '').trim().toLowerCase();
+ }
+
function renderMemoryKinds(kinds) {
const sel = $('sa_mem_kind');
if (!sel) {
@@ -4713,16 +4771,40 @@
}
}
+ function filteredMemoryRows() {
+ const filter = memoryKindFilter();
+ const scope = memoryScopeFilter();
+ const q = memorySearchFilter();
+ const persona = $('sa_persona')?.value || 'neutral';
+ return (state.memoryRows || []).filter((m) => {
+ if (filter !== 'all' && m.kind !== filter) {
+ return false;
+ }
+ if (scope === 'shared' && m.scope !== 'shared') {
+ return false;
+ }
+ if (scope === 'personal' && !(m.scope === 'personal' && (m.persona === persona || !m.persona))) {
+ return false;
+ }
+ if (q) {
+ const hay = `${m.kind || ''} ${m.key || ''} ${m.text || ''}`.toLowerCase();
+ if (!hay.includes(q)) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
function renderMemoryList() {
const root = $('sa_mem_list');
if (!root) {
return;
}
- const filter = memoryKindFilter();
- const rows = (state.memoryRows || []).filter((m) => filter === 'all' || m.kind === filter);
+ const rows = filteredMemoryRows();
root.innerHTML = '';
if (!rows.length) {
- root.innerHTML = '
Память пуста — она наполняется из карточек, seed и патчей memory_upsert.
';
+ root.innerHTML = 'Крафт-память пуста — карточки, seed и патчи memory_upsert.
';
return;
}
for (const row of rows) {
@@ -4800,6 +4882,533 @@
);
}
+ function clearCraftMemory(opts = {}) {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const label = opts.label || 'крафт-память';
+ if (!window.confirm(`Очистить ${label}? Bundled seed останется.`)) {
+ return;
+ }
+ const body = {
+ scope: opts.scope || '',
+ kind: opts.kind || '',
+ persona: opts.persona || '',
+ };
+ genericRequest(
+ 'AssistentClearMemory',
+ body,
+ (data) => {
+ setStatus(`Удалено крафт-записей: ${data?.deleted ?? 0}`);
+ refreshMemoryList();
+ },
+ 0,
+ (err) => setStatus(String(err || 'Очистка не удалась')),
+ );
+ }
+
+ function setSettingsTab(id) {
+ state.settingsTab = id || 'behavior';
+ document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
+ const on = btn.getAttribute('data-stab') === state.settingsTab;
+ btn.classList.toggle('sa-stab-active', on);
+ btn.setAttribute('aria-selected', on ? 'true' : 'false');
+ });
+ document.querySelectorAll('#sa_settings .sa-spane').forEach((pane) => {
+ pane.hidden = pane.getAttribute('data-spane') !== state.settingsTab;
+ });
+ if (state.settingsTab === 'craft') {
+ refreshMemoryList();
+ refreshWantedQueue();
+ }
+ if (state.settingsTab === 'user') {
+ refreshUserPrefs();
+ }
+ if (state.settingsTab === 'personas') {
+ renderPersonaSettingsList();
+ }
+ if (state.settingsTab === 'models') {
+ syncSettingsHealthLine();
+ const m = $('sa_model')?.value;
+ if (m && $('sa_settings_chat_model')) {
+ $('sa_settings_chat_model').value = m;
+ }
+ }
+ if (state.settingsTab === 'more') {
+ fillKnobsFromConfig(state.config);
+ }
+ }
+
+ function openSettings(tab) {
+ const s = $('sa_settings');
+ if (!s) {
+ return;
+ }
+ s.hidden = false;
+ setSettingsTab(tab || state.settingsTab || 'behavior');
+ }
+
+ function closeSettings() {
+ const s = $('sa_settings');
+ if (s) {
+ s.hidden = true;
+ }
+ }
+
+ function fillKnobsFromConfig(data) {
+ const asst = data?.assistant || state.config?.assistant || {};
+ const exact = data?.exact || state.config?.exact || state.exact || {};
+ const setNum = (id, v) => {
+ const el = $(id);
+ if (el && v != null && Number.isFinite(Number(v))) {
+ el.value = String(v);
+ }
+ };
+ setNum('sa_num_ctx', asst.num_ctx);
+ setNum('sa_history_keep', asst.history_keep_turns);
+ setNum('sa_memory_top_k', asst.memory_top_k);
+ const w = asst.user_prefs_weight != null ? Number(asst.user_prefs_weight) : 1;
+ const weightEl = $('sa_user_prefs_weight');
+ if (weightEl) {
+ weightEl.value = String(Math.max(0, Math.min(1.5, w)));
+ const lab = $('sa_user_prefs_weight_val');
+ if (lab) {
+ lab.textContent = Number(weightEl.value).toFixed(1);
+ }
+ }
+ const turbo = exact.profiles?.turbo || {};
+ const raw = exact.profiles?.raw || {};
+ setNum('sa_exact_turbo_steps', turbo.steps);
+ setNum('sa_exact_turbo_cfg', turbo.cfg);
+ setNum('sa_exact_turbo_sigma', turbo.sigma_shift);
+ setNum('sa_exact_raw_steps', raw.steps);
+ setNum('sa_exact_raw_cfg', raw.cfg);
+ setNum('sa_exact_raw_sigma', raw.sigma_shift);
+ }
+
+ function saveKnobs() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const num = (id) => {
+ const v = parseFloat($(id)?.value);
+ return Number.isFinite(v) ? v : null;
+ };
+ const assistant = {
+ num_ctx: num('sa_num_ctx'),
+ history_keep_turns: num('sa_history_keep'),
+ memory_top_k: num('sa_memory_top_k'),
+ user_prefs_weight: num('sa_user_prefs_weight'),
+ };
+ Object.keys(assistant).forEach((k) => {
+ if (assistant[k] == null) {
+ delete assistant[k];
+ }
+ });
+ const exact = {
+ profiles: {
+ turbo: {
+ steps: num('sa_exact_turbo_steps'),
+ cfg: num('sa_exact_turbo_cfg'),
+ sigma_shift: num('sa_exact_turbo_sigma'),
+ },
+ raw: {
+ steps: num('sa_exact_raw_steps'),
+ cfg: num('sa_exact_raw_cfg'),
+ sigma_shift: num('sa_exact_raw_sigma'),
+ },
+ },
+ };
+ genericRequest(
+ 'AssistentSaveKnobs',
+ { assistant, exact },
+ (data) => {
+ if (data?.assistant || data?.exact) {
+ applyConfigPayload({
+ ...state.config,
+ assistant: data.assistant || state.config?.assistant,
+ exact: data.exact || state.config?.exact,
+ });
+ }
+ setStatus('Knobs сохранены в overlay');
+ },
+ 0,
+ (err) => setStatus(String(err || 'Не удалось сохранить knobs')),
+ );
+ }
+
+ function syncSettingsHealthLine() {
+ const line = $('sa_settings_health_line');
+ const badge = $('sa_ollama_health');
+ if (line && badge) {
+ line.textContent = badge.textContent || 'Ollama · …';
+ line.className = 'sa-settings-health ' + (badge.className || '').replace('sa-health', '').trim();
+ }
+ }
+
+ function personaSourceLabel(source) {
+ if (source === 'overlay') {
+ return 'моя';
+ }
+ if (source === 'overlay+bundled') {
+ return 'встроено+правка';
+ }
+ return 'встроено';
+ }
+
+ function renderPersonaSettingsList() {
+ const root = $('sa_persona_list');
+ if (!root) {
+ return;
+ }
+ const list = state.personas || state.config?.personas || [];
+ const cur = state.settingsPersonaId || $('sa_persona')?.value || list[0]?.id;
+ state.settingsPersonaId = cur;
+ root.innerHTML = '';
+ for (const p of list) {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'sa-persona-item' + (p.id === cur ? ' sa-persona-item-active' : '');
+ const accent = p.accent || 'currentColor';
+ btn.innerHTML = `${escapeHtml(p.title || p.id)}
${escapeHtml(personaSourceLabel(p.source))}
`;
+ btn.addEventListener('click', () => {
+ state.settingsPersonaId = p.id;
+ renderPersonaSettingsList();
+ loadPersonaPreview(p.id);
+ });
+ root.appendChild(btn);
+ }
+ syncPersonaPanelActions();
+ if (cur) {
+ loadPersonaPreview(cur);
+ }
+ }
+
+ function syncPersonaPanelActions() {
+ const id = state.settingsPersonaId;
+ const p = (state.personas || []).find((x) => x.id === id);
+ const canDelete = p && (p.source === 'overlay' || p.source === 'overlay+bundled');
+ const del = $('sa_btn_persona_delete_panel');
+ if (del) {
+ del.disabled = !canDelete;
+ }
+ }
+
+ function loadPersonaPreview(id) {
+ const box = $('sa_persona_preview');
+ if (!box || typeof genericRequest !== 'function') {
+ return;
+ }
+ box.innerHTML = 'Загрузка…
';
+ genericRequest(
+ 'AssistentGetPersonaShelves',
+ { persona: id },
+ (data) => {
+ const summary = data?.identity_summary || '';
+ const src = data?.source || '';
+ box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
+ syncPersonaPanelActions();
+ },
+ 0,
+ (err) => {
+ box.innerHTML = `${escapeHtml(String(err || 'ошибка'))}
`;
+ },
+ );
+ }
+
+ function exportSelectedPersona() {
+ const id = state.settingsPersonaId || $('sa_persona')?.value;
+ if (!id || typeof genericRequest !== 'function') {
+ return;
+ }
+ genericRequest(
+ 'AssistentExportPersona',
+ { persona: id },
+ (data) => {
+ const pack = data?.pack;
+ if (!pack) {
+ setStatus('Пустой экспорт');
+ return;
+ }
+ const blob = new Blob([JSON.stringify(pack, null, 2)], { type: 'application/json' });
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = `${pack.id || id}.assistent-persona.json`;
+ a.click();
+ URL.revokeObjectURL(a.href);
+ setStatus(`Экспорт: ${a.download}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Экспорт не удался')),
+ );
+ }
+
+ function importPersonaFile(file) {
+ if (!file || typeof genericRequest !== 'function') {
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ let pack;
+ try {
+ pack = JSON.parse(String(reader.result || ''));
+ } catch (e) {
+ setStatus('Невалидный JSON');
+ return;
+ }
+ let newId = pack?.id || '';
+ if ((state.personas || []).some((p) => p.id === newId && (p.source === 'bundled' || p.source === 'overlay+bundled'))) {
+ newId = window.prompt('Id занят bundled — новый id:', `${newId}_import`) || '';
+ }
+ genericRequest(
+ 'AssistentImportPersona',
+ { pack, new_id: newId || null, overwrite: false },
+ (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ renderPersonaOptions(data.personas, data.persona?.id);
+ }
+ state.settingsPersonaId = data?.persona?.id || newId;
+ renderPersonaSettingsList();
+ setStatus(`Импорт: ${data?.persona?.id || newId}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Импорт не удался')),
+ );
+ };
+ reader.readAsText(file);
+ }
+
+ function cloneSelectedPersona() {
+ const from = state.settingsPersonaId || $('sa_persona')?.value;
+ if (!from) {
+ return;
+ }
+ const to = window.prompt('Новый id личности:', `${from}_copy`);
+ if (!to) {
+ return;
+ }
+ genericRequest(
+ 'AssistentClonePersona',
+ { from, to, title: to },
+ (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ renderPersonaOptions(data.personas, to);
+ }
+ state.settingsPersonaId = to;
+ renderPersonaSettingsList();
+ setStatus(`Клон: ${to}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Клон не удался')),
+ );
+ }
+
+ function deleteSelectedOverlayPersona() {
+ const id = state.settingsPersonaId;
+ const p = (state.personas || []).find((x) => x.id === id);
+ if (!p || (p.source !== 'overlay' && p.source !== 'overlay+bundled')) {
+ setStatus('Можно удалить только overlay');
+ return;
+ }
+ if (!window.confirm(`Удалить overlay-личность «${id}»?`)) {
+ return;
+ }
+ genericRequest(
+ 'AssistentDeletePersona',
+ { persona: id },
+ (data) => {
+ if (Array.isArray(data?.personas)) {
+ state.personas = data.personas;
+ renderPersonaOptions(data.personas, data.default_persona);
+ }
+ state.settingsPersonaId = data?.default_persona || null;
+ renderPersonaSettingsList();
+ setStatus(`Удалено: ${id}`);
+ },
+ 0,
+ (err) => setStatus(String(err || 'Удаление не удалось')),
+ );
+ }
+
+ function refreshUserPrefs() {
+ if (typeof genericRequest !== 'function') {
+ return;
+ }
+ const persona = $('sa_persona')?.value || 'neutral';
+ genericRequest(
+ 'AssistentListUserPrefs',
+ { persona, limit: 200 },
+ (data) => {
+ state.userPrefs = Array.isArray(data?.prefs) ? data.prefs : [];
+ renderUserPrefsLists();
+ },
+ 0,
+ (err) => setStatus(String(err || 'User prefs недоступны')),
+ );
+ }
+
+ function renderUserPrefsLists() {
+ const persona = $('sa_persona')?.value || 'neutral';
+ const global = (state.userPrefs || []).filter((p) => p.scope === 'global');
+ const personal = (state.userPrefs || []).filter((p) => p.scope === 'persona' && (p.persona_id === persona || p.persona === persona));
+ const fill = (rootId, rows) => {
+ const root = $(rootId);
+ if (!root) {
+ return;
+ }
+ root.innerHTML = '';
+ if (!rows.length) {
+ root.innerHTML = 'Пусто
';
+ return;
+ }
+ for (const row of rows) {
+ const el = document.createElement('div');
+ el.className = 'sa-mem-row';
+ const pin = row.pinned ? ' ★' : '';
+ el.innerHTML = `${escapeHtml(row.key || '')}${pin}
${escapeHtml(clipDebug(row.text, 200))}
`;
+ el.querySelector('.sa-mem-row-body')?.addEventListener('click', () => editUserPref(row));
+ el.querySelector('.sa-mem-row-body')?.setAttribute('title', 'Клик — редактировать');
+ const pinBtn = document.createElement('button');
+ pinBtn.type = 'button';
+ pinBtn.className = 'basic-button sa-mem-forget';
+ pinBtn.textContent = row.pinned ? '★' : '☆';
+ pinBtn.title = row.pinned ? 'Unpin' : 'Pin';
+ pinBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleUserPrefPin(row);
+ });
+ el.appendChild(pinBtn);
+ const forget = document.createElement('button');
+ forget.type = 'button';
+ forget.className = 'basic-button sa-mem-forget';
+ forget.textContent = '×';
+ forget.title = 'Забыть';
+ forget.addEventListener('click', (e) => {
+ e.stopPropagation();
+ forgetUserPref(row);
+ });
+ el.appendChild(forget);
+ root.appendChild(el);
+ }
+ };
+ fill('sa_prefs_global', global);
+ fill('sa_prefs_persona', personal);
+ }
+
+ function editUserPref(row) {
+ const text = window.prompt('Текст факта:', row.text || '');
+ if (text == null || !String(text).trim()) {
+ return;
+ }
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ {
+ key: row.key,
+ text: String(text).trim(),
+ scope: row.scope || 'global',
+ persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
+ source: 'user',
+ pinned: !!row.pinned,
+ },
+ () => refreshUserPrefs(),
+ 0,
+ (err) => setStatus(String(err || 'Не удалось сохранить')),
+ );
+ }
+
+ function toggleUserPrefPin(row) {
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ {
+ key: row.key,
+ text: row.text,
+ scope: row.scope || 'global',
+ persona: row.persona_id || row.persona || $('sa_persona')?.value || 'neutral',
+ source: row.source || 'user',
+ pinned: !row.pinned,
+ },
+ () => refreshUserPrefs(),
+ 0,
+ (err) => setStatus(String(err || 'Не удалось pin')),
+ );
+ }
+
+ function addUserPref(scope) {
+ const key = window.prompt('Ключ (stable-id):', scope === 'global' ? 'prefer' : 'tone');
+ if (!key) {
+ return;
+ }
+ const text = window.prompt('Текст факта:', '');
+ if (!text) {
+ return;
+ }
+ genericRequest(
+ 'AssistentUpsertUserPref',
+ {
+ key: key.trim(),
+ text: text.trim(),
+ scope,
+ persona: $('sa_persona')?.value || 'neutral',
+ source: 'user',
+ pinned: false,
+ },
+ () => {
+ refreshUserPrefs();
+ setStatus('Сохранено');
+ },
+ 0,
+ (err) => setStatus(String(err || 'Не удалось сохранить')),
+ );
+ }
+
+ function forgetUserPref(row) {
+ genericRequest(
+ 'AssistentForgetUserPref',
+ {
+ key: row.key,
+ scope: row.scope || 'global',
+ persona: row.persona_id || row.persona || $('sa_persona')?.value,
+ },
+ () => refreshUserPrefs(),
+ 0,
+ (err) => setStatus(String(err || 'Не удалось забыть')),
+ );
+ }
+
+ function clearUserPrefs(scope) {
+ const labels = { global: 'общие prefs', persona: 'prefs этой личности', all: 'все prefs о пользователе' };
+ if (!window.confirm(`Очистить ${labels[scope] || scope}?`)) {
+ return;
+ }
+ genericRequest(
+ 'AssistentClearUserPrefs',
+ { scope, persona: $('sa_persona')?.value || 'neutral' },
+ (data) => {
+ setStatus(`Удалено: ${data?.deleted ?? 0}`);
+ refreshUserPrefs();
+ },
+ 0,
+ (err) => setStatus(String(err || 'Очистка не удалась')),
+ );
+ }
+
+ function resetUiState() {
+ if (!window.confirm('Сбросить UI-state (local + disk)? Настройки Ollama и prefs останутся.')) {
+ return;
+ }
+ const keys = Object.keys(localStorage).filter((k) => k.startsWith('swarm_assistent_'));
+ for (const k of keys) {
+ if (k === LS_TASTE) {
+ continue;
+ }
+ localStorage.removeItem(k);
+ }
+ diskPersist()?.saveUiState?.({});
+ setStatus('UI-state сброшен — обнови страницу');
+ }
+
function modelKeyLeaf(name) {
return String(name || '')
.replace(/\\/g, '/')
@@ -4877,6 +5486,7 @@
el.title = title || text;
el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
el.classList.add(`sa-health-${level}`);
+ syncSettingsHealthLine();
}
function probeOllamaHealth() {
@@ -5871,7 +6481,7 @@
} else {
why.push('последнего патча Assistent ещё нет');
}
- why.push('приоритет: user → session_exact → exact(+persona) → live UI → memory_hits (shared+personal)');
+ why.push('приоритет: user → About the user → session_exact → exact(+persona) → live UI → craft memory_hits');
const lines = [
'### Debug Assistent',
@@ -6725,18 +7335,75 @@
$('sa_btn_settings')?.addEventListener('click', () => {
const s = $('sa_settings');
if (s) {
- s.hidden = !s.hidden;
- if (!s.hidden) {
- refreshMemoryList();
- refreshWantedQueue();
+ if (s.hidden) {
+ openSettings(state.settingsTab || 'behavior');
+ } else {
+ closeSettings();
}
}
});
+ $('sa_settings_close')?.addEventListener('click', () => closeSettings());
+ document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
+ btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
+ });
$('sa_btn_mem_refresh')?.addEventListener('click', () => {
refreshMemoryList();
refreshWantedQueue();
});
$('sa_mem_kind')?.addEventListener('change', renderMemoryList);
+ $('sa_mem_scope')?.addEventListener('change', renderMemoryList);
+ $('sa_mem_search')?.addEventListener('input', () => renderMemoryList());
+ $('sa_btn_mem_clear_kind')?.addEventListener('click', () => {
+ const kind = memoryKindFilter();
+ clearCraftMemory({ kind: kind === 'all' ? '' : kind, label: kind === 'all' ? 'весь крафт (фильтр типа)' : `тип ${kind}` });
+ });
+ $('sa_btn_mem_clear_persona')?.addEventListener('click', () => {
+ clearCraftMemory({ scope: 'personal', persona: $('sa_persona')?.value || 'neutral', label: 'крафт этой личности' });
+ });
+ $('sa_btn_mem_clear_shared')?.addEventListener('click', () => {
+ clearCraftMemory({ scope: 'shared', label: 'общую крафт-память' });
+ });
+ $('sa_btn_mem_clear_all')?.addEventListener('click', () => {
+ clearCraftMemory({ label: 'весь крафт (non-bundled)' });
+ });
+ $('sa_btn_prefs_refresh')?.addEventListener('click', () => refreshUserPrefs());
+ $('sa_btn_pref_add_global')?.addEventListener('click', () => addUserPref('global'));
+ $('sa_btn_pref_add_persona')?.addEventListener('click', () => addUserPref('persona'));
+ $('sa_btn_prefs_clear_global')?.addEventListener('click', () => clearUserPrefs('global'));
+ $('sa_btn_prefs_clear_persona')?.addEventListener('click', () => clearUserPrefs('persona'));
+ $('sa_btn_prefs_clear_all')?.addEventListener('click', () => clearUserPrefs('all'));
+ $('sa_user_prefs_weight')?.addEventListener('input', () => {
+ const lab = $('sa_user_prefs_weight_val');
+ if (lab) {
+ lab.textContent = Number($('sa_user_prefs_weight').value).toFixed(1);
+ }
+ });
+ $('sa_user_prefs_weight')?.addEventListener('change', () => saveKnobs());
+ $('sa_memory_top_k')?.addEventListener('change', () => saveKnobs());
+ $('sa_btn_knobs_save')?.addEventListener('click', () => saveKnobs());
+ $('sa_btn_reset_ui')?.addEventListener('click', () => resetUiState());
+ $('sa_btn_persona_export')?.addEventListener('click', () => exportSelectedPersona());
+ $('sa_btn_persona_import')?.addEventListener('click', () => $('sa_persona_import_file')?.click());
+ $('sa_persona_import_file')?.addEventListener('change', (e) => {
+ const file = e.target?.files?.[0];
+ if (file) {
+ importPersonaFile(file);
+ }
+ e.target.value = '';
+ });
+ $('sa_btn_persona_clone')?.addEventListener('click', () => cloneSelectedPersona());
+ $('sa_btn_persona_delete_panel')?.addEventListener('click', () => deleteSelectedOverlayPersona());
+ $('sa_btn_settings_health')?.addEventListener('click', () => {
+ probeOllamaHealth();
+ setTimeout(syncSettingsHealthLine, 400);
+ });
+ $('sa_settings_chat_model')?.addEventListener('change', () => {
+ const v = $('sa_settings_chat_model')?.value;
+ if (v && $('sa_model')) {
+ $('sa_model').value = v;
+ saveSettings();
+ }
+ });
$('sa_btn_look_result')?.addEventListener('click', () => askLookAtResult());
$('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
document.addEventListener('keydown', (e) => {
@@ -6746,7 +7413,7 @@
let closed = false;
const settings = $('sa_settings');
if (settings && !settings.hidden) {
- settings.hidden = true;
+ closeSettings();
closed = true;
}
if (state.chatsPanelOpen) {
@@ -6854,7 +7521,13 @@
$('sa_board_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_clear_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_base_url')?.addEventListener('change', saveSettings);
- $('sa_model')?.addEventListener('change', saveSettings);
+ $('sa_model')?.addEventListener('change', () => {
+ const v = $('sa_model')?.value;
+ if (v && $('sa_settings_chat_model')) {
+ $('sa_settings_chat_model').value = v;
+ }
+ saveSettings();
+ });
$('sa_embed_model')?.addEventListener('change', () => {
state.preferredEmbed = $('sa_embed_model')?.value || '';
saveSettings();
diff --git a/Assets/assistent.patch.js b/Assets/assistent.patch.js
index 0e84779..765907b 100644
--- a/Assets/assistent.patch.js
+++ b/Assets/assistent.patch.js
@@ -14,6 +14,8 @@ window.SA = window.SA || {};
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack',
+ 'memories', 'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
+ 'controls', 'persona_clone', 'persona_shelves', 'persona', 'notes',
];
const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs
index 9f25260..456f49d 100644
--- a/AssistentChatPipeline.cs
+++ b/AssistentChatPipeline.cs
@@ -30,6 +30,26 @@ public partial class SwarmAssistentExtension
}
}
+ if (Memory is not null)
+ {
+ try
+ {
+ JObject asst = Config.LoadAssistant(pid);
+ double weight = asst["user_prefs_weight"]?.Value() ?? 1.0;
+ int maxPrefs = asst["user_prefs_max"]?.Value() ?? 16;
+ string about = Memory.FormatUserPrefsBlock(pid, weight, maxPrefs);
+ if (!string.IsNullOrWhiteSpace(about))
+ {
+ system.AppendLine();
+ system.AppendLine(about);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"BuildOllamaMessages user prefs: {ex.Message}");
+ }
+ }
+
JObject exact = Config.LoadExactForPrompt(pid);
if (exact is not null && exact.Count > 0)
{
@@ -170,6 +190,7 @@ public partial class SwarmAssistentExtension
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
JObject patch = TryParsePatch(reply);
await ApplyMemoryActions(root, patch, embed, pid);
+ ApplyUserPrefActions(patch, pid);
ApplyPersonaActions(patch, ref pid);
if (hop + 1 >= maxHops)
{
@@ -382,6 +403,19 @@ public partial class SwarmAssistentExtension
ctx = new JObject { ["_raw_context"] = contextJson };
}
ctx["memory_hits"] = hits ?? new JArray();
+ ctx.Remove("taste_profile");
+ try
+ {
+ string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral";
+ JObject asst = Config?.LoadAssistant(pid) ?? new JObject();
+ double weight = asst["user_prefs_weight"]?.Value() ?? 1.0;
+ int maxPrefs = asst["user_prefs_max"]?.Value() ?? 16;
+ ctx["user_prefs_count"] = Memory?.SelectUserPrefsForPrompt(pid, weight, maxPrefs).Count ?? 0;
+ }
+ catch
+ {
+ ctx["user_prefs_count"] = 0;
+ }
// Never re-inject full Exact into live context (already in system prompt).
ctx.Remove("exact");
if (ctx["session_exact"] is null)
@@ -624,4 +658,60 @@ public partial class SwarmAssistentExtension
}
}
}
+
+ void ApplyUserPrefActions(JObject patch, string personaId)
+ {
+ 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, "user_pref_upsert", StringComparison.OrdinalIgnoreCase))
+ {
+ upsert = true;
+ }
+ if (string.Equals(s, "user_pref_forget", StringComparison.OrdinalIgnoreCase))
+ {
+ forget = true;
+ }
+ }
+ }
+ JArray prefs = patch["user_prefs"] as JArray;
+ if (prefs is null || prefs.Count == 0)
+ {
+ return;
+ }
+ string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
+ foreach (JToken t in prefs)
+ {
+ if (t is not JObject mo)
+ {
+ continue;
+ }
+ string key = mo["key"]?.ToString() ?? "";
+ string text = mo["text"]?.ToString() ?? "";
+ string scope = mo["scope"]?.ToString() ?? "global";
+ bool pinned = mo["pinned"]?.Value() == true;
+ try
+ {
+ if (forget && string.IsNullOrWhiteSpace(text))
+ {
+ Memory.ForgetUserPref(key, scope, pid);
+ }
+ else if (upsert || !string.IsNullOrWhiteSpace(text))
+ {
+ Memory.UpsertUserPref(key, text, scope, pid, "agent", pinned);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"ApplyUserPrefActions: {ex.Message}");
+ }
+ }
+ }
}
diff --git a/AssistentConfig.cs b/AssistentConfig.cs
index bf1a7c3..0cb7a17 100644
--- a/AssistentConfig.cs
+++ b/AssistentConfig.cs
@@ -715,6 +715,145 @@ public sealed class AssistentConfig
/// Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.
public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId));
+ /// DeepMerge sparse keys into Assistent/_base/<fileName> (disk overlay only).
+ public JObject MergeOverlayBaseJson(string fileName, JObject sparse)
+ {
+ string safe = Path.GetFileName(fileName ?? "");
+ if (string.IsNullOrWhiteSpace(safe) || !safe.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("invalid overlay base json name");
+ }
+ string dir = Path.Combine(_overlayRoot, "_base");
+ Directory.CreateDirectory(dir);
+ string path = Path.Combine(dir, safe);
+ JObject existing = TryReadJson(path) ?? new JObject();
+ JObject merged = DeepMerge(existing, sparse ?? new JObject());
+ File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
+ return merged;
+ }
+
+ /// Export a shareable persona pack (merged shelves + controls + personal memory-seed).
+ public JObject ExportPersonaPack(string personaId)
+ {
+ string id = SafeId(personaId) ?? throw new ArgumentException("invalid persona id");
+ JObject shelves = LoadIdentityParts(id);
+ JObject controls = LoadControlsSchema(id);
+ JObject controlValues = LoadControlValues(id);
+ JArray seed = [];
+ foreach (JObject doc in LoadMemorySeedDocs())
+ {
+ string persona = AssistentMemory.NormalizePersona(doc["persona"]?.ToString());
+ if (!string.Equals(persona, id, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+ string kind = doc["kind"]?.ToString() ?? "note";
+ string key = doc["key"]?.ToString() ?? "";
+ string text = doc["text"]?.ToString() ?? "";
+ if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
+ {
+ continue;
+ }
+ seed.Add(new JObject
+ {
+ ["kind"] = kind,
+ ["key"] = key,
+ ["text"] = text,
+ });
+ }
+ JObject metaShelf = shelves["persona"] as JObject ?? new JObject();
+ return new JObject
+ {
+ ["format"] = "swarm-assistent-persona",
+ ["version"] = 1,
+ ["id"] = id,
+ ["title"] = metaShelf["title"]?.ToString() ?? id,
+ ["shelves"] = shelves,
+ ["exact_controls"] = controlValues,
+ ["controls_schema"] = controls,
+ ["memory_seed"] = seed,
+ };
+ }
+
+ /// Import pack into overlay personas/<id>. Never overwrites bundled files.
+ public JObject ImportPersonaPack(JObject pack, string newId = null, bool overwriteOverlay = false)
+ {
+ if (pack is null || !string.Equals(pack["format"]?.ToString(), "swarm-assistent-persona", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException("invalid persona pack format");
+ }
+ string requested = SafeId(newId) ?? SafeId(pack["id"]?.ToString());
+ if (requested is null)
+ {
+ throw new ArgumentException("invalid persona id");
+ }
+ string id = requested;
+ if (IsBundledPersona(id) && !IsOverlayPersona(id))
+ {
+ // Force rename away from bundled id
+ id = SafeId(id + "_import") ?? throw new InvalidOperationException("cannot import over bundled id — pick a new id");
+ if (IsBundledPersona(id) || (IsOverlayPersona(id) && !overwriteOverlay))
+ {
+ throw new InvalidOperationException($"id '{requested}' is bundled — pass new_id");
+ }
+ }
+ if (IsOverlayPersona(id) && !overwriteOverlay)
+ {
+ throw new InvalidOperationException($"overlay persona '{id}' already exists — pass overwrite or new_id");
+ }
+ string dest = Path.Combine(_overlayRoot, "personas", id);
+ Directory.CreateDirectory(dest);
+ if (pack["shelves"] is JObject shelves)
+ {
+ foreach (JProperty prop in shelves.Properties())
+ {
+ string fileName = prop.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
+ ? Path.GetFileName(prop.Name)
+ : prop.Name + ".json";
+ if (!IsWritableShelfName(fileName) && !string.Equals(fileName, "persona.json", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+ if (prop.Value is JObject jo)
+ {
+ File.WriteAllText(Path.Combine(dest, fileName),
+ jo.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
+ }
+ }
+ }
+ string personaPath = Path.Combine(dest, "persona.json");
+ JObject meta = TryReadJson(personaPath) ?? new JObject();
+ meta["id"] = id;
+ if (!string.IsNullOrWhiteSpace(pack["title"]?.ToString()))
+ {
+ meta["title"] = pack["title"]?.ToString();
+ }
+ File.WriteAllText(personaPath, meta.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
+ if (pack["controls_schema"] is JObject schema && schema.Count > 0)
+ {
+ File.WriteAllText(Path.Combine(dest, "controls.json"),
+ schema.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
+ }
+ if (pack["exact_controls"] is JObject ctrl && ctrl.Count > 0)
+ {
+ File.WriteAllText(Path.Combine(dest, "exact.json"),
+ new JObject { ["controls"] = ctrl }.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
+ }
+ if (pack["memory_seed"] is JArray seedArr && seedArr.Count > 0)
+ {
+ string seedDir = Path.Combine(dest, "memory-seed");
+ Directory.CreateDirectory(seedDir);
+ File.WriteAllText(Path.Combine(seedDir, "imported.json"),
+ seedArr.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
+ }
+ return new JObject
+ {
+ ["id"] = id,
+ ["title"] = meta["title"]?.ToString() ?? id,
+ ["source"] = "overlay",
+ };
+ }
+
public JObject LoadModelProfile(string personaId)
{
JObject assistant = LoadAssistant(personaId);
@@ -962,7 +1101,37 @@ public sealed class AssistentConfig
return char.ToUpperInvariant(key[0]) + key[1..];
}
- public string RenderIdentityBlock(string personaId)
+ static readonly string[] DefaultIdentityAlwaysShelves =
+ ["persona", "voice", "rules", "likes", "dislikes"];
+
+ /// Shelves always injected into the system prompt. Lore shelves load via persona_read hop.
+ public HashSet IdentityAlwaysShelves(string personaId)
+ {
+ HashSet set = new(StringComparer.OrdinalIgnoreCase);
+ JObject asst = LoadAssistant(SafeId(personaId) ?? "neutral") ?? new JObject();
+ if (asst["identity_always_shelves"] is JArray arr && arr.Count > 0)
+ {
+ foreach (JToken t in arr)
+ {
+ string name = SafeId(t?.ToString()) ?? t?.ToString()?.Trim();
+ if (!string.IsNullOrWhiteSpace(name))
+ {
+ set.Add(name);
+ }
+ }
+ }
+ if (set.Count == 0)
+ {
+ foreach (string s in DefaultIdentityAlwaysShelves)
+ {
+ set.Add(s);
+ }
+ }
+ return set;
+ }
+
+ /// Render always-on identity (voice/taste). Pass includeAllShelves for author pack / persona_read.
+ public string RenderIdentityBlock(string personaId, bool includeAllShelves = false, IEnumerable onlyShelves = null)
{
string id = SafeId(personaId) ?? "neutral";
JObject parts = LoadIdentityParts(id);
@@ -976,12 +1145,26 @@ public sealed class AssistentConfig
sb.AppendLine($"*{tagline}*");
}
+ HashSet allow = null;
+ if (onlyShelves is not null)
+ {
+ allow = new HashSet(onlyShelves.Where(s => !string.IsNullOrWhiteSpace(s)), StringComparer.OrdinalIgnoreCase);
+ }
+ else if (!includeAllShelves)
+ {
+ allow = IdentityAlwaysShelves(id);
+ }
+
foreach (JProperty prop in parts.Properties())
{
if (prop.Name is "persona" or "extra")
{
continue;
}
+ if (allow is not null && !allow.Contains(prop.Name))
+ {
+ continue;
+ }
if (prop.Value is not JObject shelf || !shelf.Properties().Any())
{
continue;
@@ -991,22 +1174,87 @@ public sealed class AssistentConfig
AppendTokenMarkdown(sb, shelf, 0);
}
- string controlsBlock = RenderControlsBlock(id);
- if (!string.IsNullOrWhiteSpace(controlsBlock))
+ // Controls schema/meanings are fat; Exact already carries current values. Only author/full dump.
+ if (includeAllShelves && onlyShelves is null)
{
- sb.AppendLine();
- sb.AppendLine(controlsBlock);
- }
+ string controlsBlock = RenderControlsBlock(id);
+ if (!string.IsNullOrWhiteSpace(controlsBlock))
+ {
+ sb.AppendLine();
+ sb.AppendLine(controlsBlock);
+ }
- string extra = parts["extra"]?.ToString() ?? "";
- if (!string.IsNullOrWhiteSpace(extra))
- {
- sb.AppendLine();
- sb.AppendLine(extra.Trim());
+ string extra = parts["extra"]?.ToString() ?? "";
+ if (!string.IsNullOrWhiteSpace(extra))
+ {
+ sb.AppendLine();
+ sb.AppendLine(extra.Trim());
+ }
}
return sb.ToString().TrimEnd();
}
+ /// Markdown for lore shelves not in always-on (persona_read hop).
+ public string RenderPersonaReadBlock(string personaId, IEnumerable shelfNames)
+ {
+ string id = SafeId(personaId) ?? "neutral";
+ HashSet always = IdentityAlwaysShelves(id);
+ List want = [];
+ if (shelfNames is not null)
+ {
+ foreach (string raw in shelfNames)
+ {
+ string name = SafeId(raw) ?? raw?.Trim();
+ if (!string.IsNullOrWhiteSpace(name) && !always.Contains(name) && !string.Equals(name, "persona", StringComparison.OrdinalIgnoreCase))
+ {
+ want.Add(name);
+ }
+ }
+ }
+ if (want.Count == 0)
+ {
+ JObject parts = LoadIdentityParts(id);
+ foreach (JProperty prop in parts.Properties())
+ {
+ if (prop.Name is "persona" or "extra")
+ {
+ continue;
+ }
+ if (always.Contains(prop.Name))
+ {
+ continue;
+ }
+ if (prop.Value is JObject shelf && shelf.Properties().Any())
+ {
+ want.Add(prop.Name);
+ }
+ }
+ string extra = parts["extra"]?.ToString() ?? "";
+ if (!string.IsNullOrWhiteSpace(extra))
+ {
+ // RenderIdentityBlock with onlyShelves skips extra; append manually below if needed.
+ }
+ }
+ if (want.Count == 0)
+ {
+ JObject parts = LoadIdentityParts(id);
+ string extraOnly = parts["extra"]?.ToString() ?? "";
+ if (string.IsNullOrWhiteSpace(extraOnly))
+ {
+ return "";
+ }
+ return $"## Persona lore: {id}\n\n{extraOnly.Trim()}";
+ }
+ string body = RenderIdentityBlock(id, includeAllShelves: false, onlyShelves: want);
+ JObject all = LoadIdentityParts(id);
+ string extraMd = all["extra"]?.ToString() ?? "";
+ if (!string.IsNullOrWhiteSpace(extraMd) && (shelfNames is null || !shelfNames.Any() || shelfNames.Any(s => string.Equals(s, "extra", StringComparison.OrdinalIgnoreCase))))
+ {
+ body = string.IsNullOrWhiteSpace(body) ? extraMd.Trim() : body + "\n\n" + extraMd.Trim();
+ }
+ return body;
+ }
+
static IEnumerable PersonaIdsUnder(string root)
{
string dir = Path.Combine(root ?? "", "personas");
diff --git a/AssistentMemory.UserPrefs.cs b/AssistentMemory.UserPrefs.cs
new file mode 100644
index 0000000..6e3f17b
--- /dev/null
+++ b/AssistentMemory.UserPrefs.cs
@@ -0,0 +1,343 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Utils;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// Facts about the human at the desk — global + per-persona, separate from craft RAG.
+public sealed partial class AssistentMemory
+{
+ const string MetaTasteMigrated = "user_prefs_taste_migrated";
+
+ void EnsureUserPrefsSchema()
+ {
+ Exec(
+ """
+ CREATE TABLE IF NOT EXISTS user_prefs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ key TEXT NOT NULL,
+ text TEXT NOT NULL,
+ scope TEXT NOT NULL DEFAULT 'global',
+ persona_id TEXT NOT NULL DEFAULT '',
+ source TEXT NOT NULL DEFAULT 'user',
+ pinned INTEGER NOT NULL DEFAULT 0,
+ updated INTEGER NOT NULL,
+ UNIQUE(key, scope, persona_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_user_prefs_scope ON user_prefs(scope, persona_id);
+ """);
+ MigrateTasteToUserPrefsOnce();
+ }
+
+ void MigrateTasteToUserPrefsOnce()
+ {
+ if (GetMeta(MetaTasteMigrated) == "1")
+ {
+ return;
+ }
+ try
+ {
+ JObject taste = GetKvObject(KvTaste);
+ if (taste is not null && taste.Count > 0)
+ {
+ long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ void AddList(string prefix, JToken arr)
+ {
+ if (arr is not JArray a)
+ {
+ return;
+ }
+ int i = 0;
+ foreach (JToken t in a)
+ {
+ string text = (t?.ToString() ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ continue;
+ }
+ UpsertUserPrefUnlocked($"{prefix}_{i++}", text, "global", "", "migrated_taste", pinned: false, now);
+ }
+ }
+ AddList("like", taste["likes"]);
+ AddList("avoid", taste["avoid"]);
+ AddList("style", taste["styles"]);
+ string notes = (taste["notes"]?.ToString() ?? "").Trim();
+ if (!string.IsNullOrWhiteSpace(notes))
+ {
+ UpsertUserPrefUnlocked("notes", notes, "global", "", "migrated_taste", pinned: false, now);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentMemory taste→user_prefs: {ex.Message}");
+ }
+ SetMeta(MetaTasteMigrated, "1");
+ }
+
+ static string NormalizePrefScope(string scope)
+ {
+ string s = (scope ?? "").Trim().ToLowerInvariant();
+ return s is "persona" or "personal" or "agent" ? "persona" : "global";
+ }
+
+ static string NormalizePrefPersona(string scope, string personaId)
+ {
+ if (NormalizePrefScope(scope) == "global")
+ {
+ return "";
+ }
+ return AssistentConfig.SafeId(personaId) ?? "neutral";
+ }
+
+ public JArray ListUserPrefs(string scope = null, string personaId = null, int limit = 200)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ List rows = [];
+ string wantScope = (scope ?? "").Trim().ToLowerInvariant();
+ string wantPersona = AssistentConfig.SafeId(personaId) ?? "";
+ using SqliteCommand cmd = _conn.CreateCommand();
+ List where = [];
+ if (wantScope is "global" or "shared" or "common")
+ {
+ where.Add("scope = 'global'");
+ }
+ else if (wantScope is "persona" or "personal")
+ {
+ where.Add("scope = 'persona'");
+ if (!string.IsNullOrWhiteSpace(wantPersona))
+ {
+ where.Add("persona_id = $persona");
+ cmd.Parameters.AddWithValue("$persona", wantPersona);
+ }
+ }
+ else if (!string.IsNullOrWhiteSpace(wantPersona))
+ {
+ // Prompt path: global ∪ this persona
+ where.Add("(scope = 'global' OR (scope = 'persona' AND persona_id = $persona))");
+ cmd.Parameters.AddWithValue("$persona", wantPersona);
+ }
+ string whereSql = where.Count > 0 ? "WHERE " + string.Join(" AND ", where) : "";
+ cmd.CommandText =
+ $"""
+ SELECT id, key, text, scope, persona_id, source, pinned, updated
+ FROM user_prefs
+ {whereSql}
+ ORDER BY pinned DESC, updated DESC
+ LIMIT $lim
+ """;
+ cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 2000));
+ using SqliteDataReader reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ string sc = reader.GetString(3);
+ string pid = reader.IsDBNull(4) ? "" : reader.GetString(4) ?? "";
+ rows.Add(new JObject
+ {
+ ["id"] = reader.GetInt64(0),
+ ["key"] = reader.GetString(1),
+ ["text"] = reader.GetString(2),
+ ["scope"] = sc,
+ ["persona"] = string.IsNullOrEmpty(pid) ? "" : pid,
+ ["persona_id"] = pid,
+ ["source"] = reader.GetString(5),
+ ["pinned"] = !reader.IsDBNull(6) && reader.GetInt64(6) != 0,
+ ["updated"] = reader.IsDBNull(7) ? 0 : reader.GetInt64(7),
+ });
+ }
+ return new JArray(rows);
+ }
+ }
+
+ public JObject UpsertUserPref(string key, string text, string scope = "global", string personaId = null, string source = "user", bool? pinned = null)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+ UpsertUserPrefUnlocked(key, text, scope, personaId, source, pinned ?? false, now);
+ return new JObject
+ {
+ ["key"] = (key ?? "").Trim(),
+ ["text"] = (text ?? "").Trim(),
+ ["scope"] = NormalizePrefScope(scope),
+ ["persona_id"] = NormalizePrefPersona(scope, personaId),
+ ["source"] = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim().ToLowerInvariant(),
+ ["pinned"] = pinned ?? false,
+ ["updated"] = now,
+ };
+ }
+ }
+
+ void UpsertUserPrefUnlocked(string key, string text, string scope, string personaId, string source, bool pinned, long updated)
+ {
+ key = (key ?? "").Trim();
+ text = (text ?? "").Trim();
+ if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
+ {
+ throw new ArgumentException("key and text required");
+ }
+ if (key.Length > 120)
+ {
+ key = key[..120];
+ }
+ string sc = NormalizePrefScope(scope);
+ string pid = NormalizePrefPersona(sc, personaId);
+ string src = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim().ToLowerInvariant();
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText =
+ """
+ INSERT INTO user_prefs(key, text, scope, persona_id, source, pinned, updated)
+ VALUES ($key, $text, $scope, $persona, $source, $pinned, $upd)
+ ON CONFLICT(key, scope, persona_id) DO UPDATE SET
+ text = excluded.text,
+ source = excluded.source,
+ pinned = excluded.pinned,
+ updated = excluded.updated
+ """;
+ cmd.Parameters.AddWithValue("$key", key);
+ cmd.Parameters.AddWithValue("$text", text);
+ cmd.Parameters.AddWithValue("$scope", sc);
+ cmd.Parameters.AddWithValue("$persona", pid);
+ cmd.Parameters.AddWithValue("$source", src);
+ cmd.Parameters.AddWithValue("$pinned", pinned ? 1 : 0);
+ cmd.Parameters.AddWithValue("$upd", updated);
+ cmd.ExecuteNonQuery();
+ }
+
+ public bool ForgetUserPref(string key, string scope = "global", string personaId = null)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ string sc = NormalizePrefScope(scope);
+ string pid = NormalizePrefPersona(sc, personaId);
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText = "DELETE FROM user_prefs WHERE key = $key AND scope = $scope AND persona_id = $persona";
+ cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
+ cmd.Parameters.AddWithValue("$scope", sc);
+ cmd.Parameters.AddWithValue("$persona", pid);
+ return cmd.ExecuteNonQuery() > 0;
+ }
+ }
+
+ public int ClearUserPrefs(string scope = null, string personaId = null)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ string want = (scope ?? "all").Trim().ToLowerInvariant();
+ using SqliteCommand cmd = _conn.CreateCommand();
+ if (want is "global" or "shared")
+ {
+ cmd.CommandText = "DELETE FROM user_prefs WHERE scope = 'global'";
+ }
+ else if (want is "persona" or "personal")
+ {
+ string pid = AssistentConfig.SafeId(personaId) ?? "";
+ if (string.IsNullOrWhiteSpace(pid))
+ {
+ cmd.CommandText = "DELETE FROM user_prefs WHERE scope = 'persona'";
+ }
+ else
+ {
+ cmd.CommandText = "DELETE FROM user_prefs WHERE scope = 'persona' AND persona_id = $persona";
+ cmd.Parameters.AddWithValue("$persona", pid);
+ }
+ }
+ else
+ {
+ cmd.CommandText = "DELETE FROM user_prefs";
+ }
+ return cmd.ExecuteNonQuery();
+ }
+ }
+
+ public int CountUserPrefs()
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ using SqliteCommand cmd = _conn.CreateCommand();
+ cmd.CommandText = "SELECT COUNT(*) FROM user_prefs";
+ return Convert.ToInt32(cmd.ExecuteScalar());
+ }
+ }
+
+ /// Select prefs for prompt injection according to weight (0=off, <1=short, 1=full, >1=must).
+ public List SelectUserPrefsForPrompt(string personaId, double weight, int maxRows)
+ {
+ if (weight <= 0)
+ {
+ return [];
+ }
+ string pid = AssistentConfig.SafeId(personaId) ?? "neutral";
+ JArray all = ListUserPrefs(null, pid, 500);
+ List list = all.OfType().ToList();
+ maxRows = Math.Clamp(maxRows, 1, 40);
+ int take = weight >= 1
+ ? maxRows
+ : Math.Max(3, (int)Math.Ceiling(maxRows * weight));
+ return list.Take(take).ToList();
+ }
+
+ public string FormatUserPrefsBlock(string personaId, double weight, int maxRows)
+ {
+ List rows = SelectUserPrefsForPrompt(personaId, weight, maxRows);
+ if (rows.Count == 0 || weight <= 0)
+ {
+ return null;
+ }
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("## About the user");
+ if (weight > 1.05)
+ {
+ sb.AppendLine("MUST respect these preferences unless the current user message overrides them this turn.");
+ }
+ sb.AppendLine("Facts about the human (global = every persona; persona = this agent only):");
+ foreach (JObject row in rows)
+ {
+ string sc = row["scope"]?.ToString() ?? "global";
+ string key = row["key"]?.ToString() ?? "";
+ string text = row["text"]?.ToString() ?? "";
+ string pin = row["pinned"]?.Value() == true ? " ★" : "";
+ sb.AppendLine($"- [{sc}] {key}{pin}: {text}");
+ }
+ return sb.ToString().TrimEnd();
+ }
+
+ /// Batch-delete craft vector rows (never bundled). Returns deleted count.
+ public int ClearCraftMemory(string scope = null, string kind = null, string persona = null)
+ {
+ lock (_lock)
+ {
+ EnsureOpen();
+ using SqliteCommand cmd = _conn.CreateCommand();
+ List where = ["source != 'bundled'"];
+ string wantScope = (scope ?? "").Trim().ToLowerInvariant();
+ if (wantScope is "shared" or "common" or "global")
+ {
+ where.Add("persona = ''");
+ }
+ else if (wantScope is "personal" || !string.IsNullOrWhiteSpace(persona))
+ {
+ string pid = NormalizePersona(persona);
+ where.Add("persona = $persona");
+ cmd.Parameters.AddWithValue("$persona", pid);
+ }
+ string k = (kind ?? "").Trim().ToLowerInvariant();
+ if (!string.IsNullOrWhiteSpace(k) && k != "all")
+ {
+ where.Add("kind = $kind");
+ cmd.Parameters.AddWithValue("$kind", k);
+ }
+ cmd.CommandText = "DELETE FROM memories WHERE " + string.Join(" AND ", where);
+ return cmd.ExecuteNonQuery();
+ }
+ }
+}
diff --git a/AssistentMemory.cs b/AssistentMemory.cs
index 613aeb5..0e8d4ed 100644
--- a/AssistentMemory.cs
+++ b/AssistentMemory.cs
@@ -138,6 +138,14 @@ public sealed partial class AssistentMemory : IDisposable
{
Logs.Debug($"AssistentMemory store schema: {ex.Message}");
}
+ try
+ {
+ EnsureUserPrefsSchema();
+ }
+ catch (Exception ex)
+ {
+ Logs.Debug($"AssistentMemory user_prefs schema: {ex.Message}");
+ }
_embedModel = GetMeta("embed_model") ?? _embedModel;
_ = int.TryParse(GetMeta("dims"), out _dims);
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
diff --git a/AssistentMemoryApi.cs b/AssistentMemoryApi.cs
index db66a72..7ad1f69 100644
--- a/AssistentMemoryApi.cs
+++ b/AssistentMemoryApi.cs
@@ -242,6 +242,39 @@ public partial class SwarmAssistentExtension
}
}
+ public async Task AssistentClearMemory(Session session, string scope = null, string kind = null, string persona = null)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ try
+ {
+ string targetPersona = persona;
+ string wantScope = (scope ?? "").Trim().ToLowerInvariant();
+ if (wantScope is "personal" && string.IsNullOrWhiteSpace(targetPersona))
+ {
+ targetPersona = Config?.DefaultPersonaId() ?? "neutral";
+ }
+ int n = Memory.ClearCraftMemory(scope, kind, targetPersona);
+ return new JObject
+ {
+ ["success"] = true,
+ ["deleted"] = n,
+ ["scope"] = scope ?? "all",
+ ["kind"] = kind ?? "all",
+ ["persona"] = AssistentMemory.IsShared(AssistentMemory.NormalizePersona(targetPersona))
+ ? "shared"
+ : AssistentMemory.NormalizePersona(targetPersona),
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"memory clear: {ex.Message}" };
+ }
+ }
+
/// The gpu-rent wanted queue (models pending the next up) — count + entries.
public async Task AssistentListWanted(Session session)
{
diff --git a/AssistentPatch.cs b/AssistentPatch.cs
index 435e0af..0d2ad47 100644
--- a/AssistentPatch.cs
+++ b/AssistentPatch.cs
@@ -19,7 +19,8 @@ public partial class SwarmAssistentExtension
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
"creativity", "intensity", "complexity", "movement",
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory",
- "memory_query", "memory_kind", "tag_query",
+ "memory_query", "memory_kind", "tag_query", "user_prefs",
+ "inventory_query", "skills", "persona_shelves", "controls",
];
static bool HasValue(JObject obj, string key)
@@ -146,6 +147,18 @@ public partial class SwarmAssistentExtension
{
return "lookup_tags";
}
+ if (ActionsContain(patch, "list_inventory") || !string.IsNullOrWhiteSpace(patch["inventory_query"]?.ToString()))
+ {
+ return "list_inventory";
+ }
+ if (ActionsContain(patch, "skill_load"))
+ {
+ return "skill_load";
+ }
+ if (ActionsContain(patch, "persona_read"))
+ {
+ return "persona_read";
+ }
if (ActionsContain(patch, "search_civitai") || !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
{
return "civitai";
diff --git a/AssistentPersonaApi.cs b/AssistentPersonaApi.cs
index 2328cbd..360323f 100644
--- a/AssistentPersonaApi.cs
+++ b/AssistentPersonaApi.cs
@@ -139,4 +139,120 @@ public partial class SwarmAssistentExtension
return new JObject { ["error"] = ex.Message };
}
}
+
+ public async Task AssistentExportPersona(Session session, string persona = null)
+ {
+ await Task.CompletedTask;
+ string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
+ try
+ {
+ JObject pack = Config.ExportPersonaPack(pid);
+ return new JObject { ["success"] = true, ["pack"] = pack };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"export persona: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentImportPersona(Session session, JObject pack = null, string new_id = null, bool overwrite = false)
+ {
+ await Task.CompletedTask;
+ try
+ {
+ JObject meta = Config.ImportPersonaPack(pack, new_id, overwrite);
+ return new JObject
+ {
+ ["success"] = true,
+ ["persona"] = meta,
+ ["personas"] = new JArray(Config.ListPersonaCatalog().Select(p => new JObject
+ {
+ ["id"] = p.id,
+ ["title"] = p.title,
+ ["accent"] = p.accent,
+ ["source"] = p.source,
+ })),
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = ex.Message };
+ }
+ }
+
+ public async Task AssistentSaveKnobs(Session session, JObject assistant = null, JObject exact = null)
+ {
+ await Task.CompletedTask;
+ try
+ {
+ JObject asstOut = null;
+ JObject exactOut = null;
+ if (assistant is not null && assistant.Count > 0)
+ {
+ JObject sparse = new();
+ foreach (string key in new[] { "num_ctx", "history_keep_turns", "memory_top_k", "user_prefs_weight", "user_prefs_max" })
+ {
+ if (assistant[key] is not null)
+ {
+ sparse[key] = assistant[key];
+ }
+ }
+ if (sparse.Count > 0)
+ {
+ asstOut = Config.MergeOverlayBaseJson("assistant.json", sparse);
+ }
+ }
+ if (exact is not null && exact.Count > 0)
+ {
+ JObject sparse = new();
+ if (exact["profiles"] is JObject profiles)
+ {
+ JObject slimProfiles = new();
+ foreach (string name in new[] { "turbo", "raw" })
+ {
+ if (profiles[name] is JObject p)
+ {
+ JObject slim = new();
+ foreach (string k in new[] { "steps", "cfg", "sigma_shift" })
+ {
+ if (p[k] is not null)
+ {
+ slim[k] = p[k];
+ }
+ }
+ if (slim.Count > 0)
+ {
+ slimProfiles[name] = slim;
+ }
+ }
+ }
+ if (slimProfiles.Count > 0)
+ {
+ sparse["profiles"] = slimProfiles;
+ }
+ }
+ if (exact["generation"] is JObject gen)
+ {
+ sparse["generation"] = gen;
+ }
+ if (sparse.Count > 0)
+ {
+ exactOut = Config.MergeOverlayBaseJson("exact.json", sparse);
+ }
+ }
+ string pid = Config.DefaultPersonaId();
+ return new JObject
+ {
+ ["success"] = true,
+ ["assistant"] = Config.LoadAssistant(pid),
+ ["exact"] = Config.LoadExact(pid),
+ ["overlay_assistant"] = asstOut,
+ ["overlay_exact"] = exactOut,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"save knobs: {ex.Message}" };
+ }
+ }
}
diff --git a/AssistentUserPrefsApi.cs b/AssistentUserPrefsApi.cs
new file mode 100644
index 0000000..a8ce3cf
--- /dev/null
+++ b/AssistentUserPrefsApi.cs
@@ -0,0 +1,96 @@
+using System;
+using System.Threading.Tasks;
+using Newtonsoft.Json.Linq;
+using SwarmUI.Accounts;
+
+namespace Mrleo1nid.SwarmAssistent;
+
+/// CRUD for About-the-user preferences (global + per-persona).
+public partial class SwarmAssistentExtension
+{
+ public async Task AssistentListUserPrefs(Session session, string scope = null, string persona = null, int limit = 200)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ try
+ {
+ string pid = AssistentConfig.SafeId(persona) ?? Config?.DefaultPersonaId() ?? "neutral";
+ JArray rows = Memory.ListUserPrefs(scope, pid, limit);
+ return new JObject
+ {
+ ["success"] = true,
+ ["prefs"] = rows,
+ ["total"] = Memory.CountUserPrefs(),
+ ["persona"] = pid,
+ };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"user prefs list: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentUpsertUserPref(Session session, string key, string text, string scope = "global", string persona = null, string source = "user", bool pinned = false)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ try
+ {
+ string pid = AssistentConfig.SafeId(persona) ?? Config?.DefaultPersonaId() ?? "neutral";
+ JObject row = Memory.UpsertUserPref(key, text, scope, pid, source, pinned);
+ return new JObject { ["success"] = true, ["pref"] = row };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"user pref upsert: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentForgetUserPref(Session session, string key, string scope = "global", string persona = null)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ if (string.IsNullOrWhiteSpace(key))
+ {
+ return new JObject { ["error"] = "key required" };
+ }
+ try
+ {
+ string pid = AssistentConfig.SafeId(persona) ?? Config?.DefaultPersonaId() ?? "neutral";
+ bool ok = Memory.ForgetUserPref(key, scope, pid);
+ return new JObject { ["success"] = ok, ["key"] = key.Trim(), ["scope"] = scope };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"user pref forget: {ex.Message}" };
+ }
+ }
+
+ public async Task AssistentClearUserPrefs(Session session, string scope = "all", string persona = null)
+ {
+ await Task.CompletedTask;
+ if (Memory is null)
+ {
+ return new JObject { ["error"] = "memory not ready" };
+ }
+ try
+ {
+ string pid = AssistentConfig.SafeId(persona) ?? Config?.DefaultPersonaId() ?? "neutral";
+ int n = Memory.ClearUserPrefs(scope, pid);
+ return new JObject { ["success"] = true, ["deleted"] = n, ["scope"] = scope ?? "all" };
+ }
+ catch (Exception ex)
+ {
+ return new JObject { ["error"] = $"user prefs clear: {ex.Message}" };
+ }
+ }
+}
diff --git a/Config/_base/assistant.json b/Config/_base/assistant.json
index 4afe903..b33e85f 100644
--- a/Config/_base/assistant.json
+++ b/Config/_base/assistant.json
@@ -5,14 +5,21 @@
"max_checkpoints_inventory": 60,
"max_wildcards_inventory": 80,
"inventory_blurb_max": 140,
+ "inventory_prompt_rich": 12,
+ "inventory_prompt_names": 24,
+ "inventory_hop_limit": 20,
"max_ref_slots": 4,
"default_pack": "write_prompt",
"default_persona": "neutral",
"embed_model": "nomic-embed-text",
- "memory_top_k": 10,
+ "memory_top_k": 8,
+ "memory_hit_chars": 240,
"memory_min_score": 0.32,
+ "user_prefs_weight": 1.0,
+ "user_prefs_max": 16,
"max_tool_hops": 4,
"tag_lookup_limit": 20,
+ "identity_always_shelves": ["persona", "voice", "rules", "likes", "dislikes"],
"memory_quotas": {
"card": 3,
"lora": 3,
diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md
index 30c3991..6bc7d55 100644
--- a/Config/_base/core/core.md
+++ b/Config/_base/core/core.md
@@ -6,110 +6,68 @@ You are **Swarm Assistent**, a collaborative art director for image generation i
When instructions conflict, apply this order (highest wins):
-1. **This core contract** — output format, never invent LoRA/checkpoint names, never use CFG 0, never depict or request anyone 17 or under (adults only).
+1. **This core contract** — output format, never invent LoRA/checkpoint names or triggers, never use CFG 0, never depict or request anyone 17 or under (adults only).
2. **Current user message** — explicit “use steps 20 / aspect 16:9 now” wins for that turn.
-3. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
-4. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged into it.
-5. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
-6. **`memory_hits` (hybrid FTS + vector RAG)** — notes, pitfalls, LoRA blurbs. Shared hits apply to every persona; personal hits overwrite shared on the same kind+key. Never override exact numbers or the user’s param request. For a missing exact row use `memory_get`; for a second search use `memory_search`; for Danbooru spelling/aliases use `lookup_tags` (do not dump tag soup into Krea prompts).
-7. Guesses — last resort only.
+3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
+4. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
+5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
+6. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
+7. **`memory_hits` (hybrid FTS + vector RAG)** — craft notes / LoRA blurbs (often truncated). Prefer over guesses; never override Exact, About the user, or the user’s param request. Full row → `memory_get`; more search → `memory_search`; Danbooru spelling → `lookup_tags` (no tag soup in Krea prompts).
+8. Guesses — last resort only.
-Exact = encyclopedia of defaults. RAG = soft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match exact (or session_exact) and the user did not ask to change them.
+Exact = defaults encyclopedia. About the user = human taste. RAG = soft craft notes. Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact (or `session_exact`) and the user did not ask to change them.
-Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. If you propose UI changes, the fence is mandatory. Keep the prose short (a few lines) — do not paste long critique templates.
+Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. Keep prose short (a few lines).
## Live context
-A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth — refreshed every chat turn:
+"Live SwarmUI context" JSON is ground truth for this 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.
-- `session_exact` / `recommended_params` — session overrides and defaults (Exact KV is in the system block above).
-- `memory_hits` are retrieved notes (hybrid FTS+vector). Each hit has `scope` (`shared`|`personal`). Trust them over guesses, but **not** over Exact or the user.
-- `has_vision_image` — if false, do not invent what the image looks like; emit `look_at` first when you need to see it.
-- `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.
+- Use only LoRA/checkpoint **names** from `available_loras` / `enabled_loras` (or Civitai hop results). Prefer listed `triggers` / `trigger_phrase` / `blurb` — **never invent**.
+- Rich entries (blurbs/triggers) are enabled + top krea-likely. Name-only rows need `list_inventory` + `inventory_query` before you rely on them.
+- `memory_hits` may be truncated (`truncated: true`) — use `memory_get` for the full text.
+- `has_vision_image` true means a real board frame exists, but **images are not in this request** until you emit `look_at`. Do not invent what the image looks like.
+- Prefer `krea_likely` / Krea architecture; ignore FLUX/SDXL. Respect current params unless asked or pack is `form_params`.
+- Init/inpaint flags and `image_slots` are in the JSON. Extra pack fields (aspect, inpaint, persona authoring) are documented in the active pack.
+
+## Memory (short)
+
+- Craft RAG write: `memory_upsert` / `memory_forget` + `memories: [{kind,key,text,scope}]` (default personal).
+- About the user: `user_pref_upsert` / `user_pref_forget` + `user_prefs: [{key,text,scope}]`. Do **not** put human taste into craft `memories`.
+- Fat memory skill text: `skill_load` + `skills: ["memory"]` when you need the full write/read playbook.
## 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):
+1. Short helpful reply in the user's language (RU or EN).
+2. One fenced JSON patch with **only fields you want to change**:
```json
{
- "prompt": "...",
- "negative": null,
- "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["..."]}],
+ "prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
+ "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["listed_trigger"]}],
"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,
- "memory_query": null,
- "memory_kind": null,
- "tag_query": null,
- "memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
- "controls": {"preference_bias": 0.35},
- "persona_clone": null,
- "persona_shelves": null,
- "persona": null,
"notes": "one-line why"
}
```
### Patch rules
-- Omit keys you are not changing.
-- Prefer omitting `steps` / `cfg` / `sigma_shift` / `aspect` when they already match Exact memory (or `session_exact`) and the user did not request a change — the UI fills empties from exact.
-- `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.
-- `controls` — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Do not invent control ids.
-- `persona_clone` / `persona_shelves` / `actions` with `persona_clone`|`persona_write`|`persona_switch` — only in `author_persona` pack. Overlay-only; never delete personas from a patch.
+- Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`.
+- `loras` replaces the full intended set for Apply. Prefer `aspect` over raw width/height.
+- Optional keys (seed, vary, init/mask, creativity/sliders, pack, controls, persona authoring, search/memory queries) — use when needed; packs list the ones for that mode.
- Do not invent model or LoRA filenames.
-- Memory: `memory_upsert` / `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal. Tools: `memory_get` + kind/key, `memory_search` + `memory_query`, `lookup_tags` + `tag_query` (Danbooru csv — spelling only, not prompt soup).
-### Actions (auto-safe)
+### Actions / hops
-- `"generate"` — after Apply, start generation. When the user explicitly asks to generate, always include this; the UI applies silently (no Apply-button strip).
-- `"search_civitai"` — Civitai search; user Confirms downloads.
+- `"generate"` — Apply + start generation when the user wants a new image.
+- `"search_civitai"` + `search_query` — Civitai hop (user Confirms downloads).
- `"interrupt"` — stop generation.
-- `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store).
-- `"memory_get"` / `"memory_search"` — hop: exact row or hybrid search.
-- `"lookup_tags"` — hop: Danbooru csv (aliases/counts). Do not emit tag soup for Krea.
-- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — author_persona only. Never `"persona_delete"`.
-- `look_at: ["generate", "ref1"]` — vision hop.
-- Pure Q&A with no change: omit the JSON patch.
+- `"memory_get"` / `"memory_search"` / `"lookup_tags"` — read hops.
+- `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
+- `"skill_load"` + `skills: ["memory"]` — load fat skill text.
+- `"persona_read"` — load lore shelves (appearance/outfits/…) not in always-on identity.
+- `"memory_upsert"` / `"memory_forget"` / `"user_pref_upsert"` / `"user_pref_forget"` — writes.
+- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — `author_persona` only. Never `"persona_delete"`.
+- `look_at: ["generate"|"ref1"|…]` — vision hop (JPEG arrives on the follow-up).
+- Pure Q&A: omit the JSON patch.
diff --git a/Config/_base/skills/memory.json b/Config/_base/skills/memory.json
index f890c9a..ee32bc3 100644
--- a/Config/_base/skills/memory.json
+++ b/Config/_base/skills/memory.json
@@ -1,6 +1,6 @@
{
"id": "memory",
"title": "Exact + vector memory",
- "default": true,
+ "default": false,
"prompt_file": "memory.md"
}
diff --git a/Config/_base/skills/memory.md b/Config/_base/skills/memory.md
index 6042e86..d8c523f 100644
--- a/Config/_base/skills/memory.md
+++ b/Config/_base/skills/memory.md
@@ -1,24 +1,32 @@
# Skill: memory
-You have three memory tools:
+You have four memory tools:
1. **Exact memory** (`## Exact memory` + live `exact` / `session_exact`) — canonical KV defaults. Always prefer Exact over RAG for numbers.
-2. **Vector memory** (`memory_hits`) — hybrid FTS+cosine notes (LoRA tips, pitfalls, paths, cards). Shared + this persona; personal overwrites shared on the same `kind`+`key`.
-3. **Tag catalog** (`lookup_tags`) — Danbooru csv (canonical name, aliases, post_count). **Not** RAG. Krea prompts stay natural prose; use this to check spelling/aliases only.
+2. **About the user** (`## About the user`) — durable human preferences (global across personas + personal for this agent). Tunable weight in settings. Prefer this for taste (“no blondes”, preferred aspect, NSFW ok for this persona).
+3. **Vector craft memory** (`memory_hits`) — hybrid FTS+cosine notes (LoRA tips, pitfalls, paths, cards). Shared + this persona; personal overwrites shared on the same `kind`+`key`.
+4. **Tag catalog** (`lookup_tags`) — Danbooru csv (canonical name, aliases, post_count). **Not** RAG. Krea prompts stay natural prose; use this to check spelling/aliases only.
## Priority
-User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hits` > guesses. Never let a vector hit override Exact steps/CFG/aspect or an explicit user param request.
+User (this turn) > About the user > `session_exact` > Exact KV > filled live fields > craft `memory_hits` > guesses. Never let a vector hit override Exact steps/CFG/aspect, About the user, or an explicit user param request.
## Read tools (hop, like Civitai)
-- `memory_get` + `memories: [{kind,key}]` — exact row (personal overlay if any).
+- `memory_get` + `memories: [{kind,key}]` — exact craft row (personal overlay if any).
- `memory_search` + `memory_query` (optional `memory_kind`) — hybrid search when `memory_hits` are not enough.
- `lookup_tags` + `tag_query` — csv lookup. Do **not** paste tag soup into the prompt.
Omit the tool action on the follow-up turn once you have results.
-## When to write (vector only)
+## When to write — About the user
+
+- Durable facts about the human: likes/avoids, hard constraints, working style with this agent.
+- Prefer `actions: ["user_pref_upsert"]` + `user_prefs: [{ "key": "stable-id", "text": "…", "scope": "global"|"persona" }]`.
+- Default **`scope: "global"`** for preferences every persona should honor. Use `"persona"` only when it is specific to this agent’s dynamic.
+- Forget with `user_pref_forget` + same key/scope (omit text).
+
+## When to write — craft vector only
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
- Bad paths / pitfalls you discovered this session.
@@ -28,7 +36,7 @@ Omit the tool action on the follow-up turn once you have results.
## When not to write
- Do not dump Exact defaults or the full inventory into vector memory.
-- Do not store the user's taste profile (sqlite `kv.taste`).
-- Do not upsert trivia already in `memory_hits`.
+- Do not store the user's taste in craft `memories` — use `user_prefs` instead.
+- Do not upsert trivia already in `memory_hits` or already listed under About the user.
- Do not upsert Danbooru tags — the csv catalog already has them.
- `memory_forget` without `scope` only removes the personal overlay.
diff --git a/README.md b/README.md
index 04a6210..a31fd2c 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
# Swarm Assistent
-SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
+SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **About the user** prefs + craft vector memory, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
-**Version 0.9.0** — Persona **shelves** (short JSON files, nested identity markdown), per-persona **Exact controls** (e.g. Leonid `preference_bias` slider), overlay **clone/author** pack (`/persona new`), UI-only delete for overlay personas. Chats/UI/taste stay in `assistent.sqlite`.
+**Version 0.10.0** — Settings panel (Поведение / Модели / Личности / О пользователе / Крафт / Ещё), **UserPrefs** (global + per-persona) with prompt weight, persona export/import, craft memory clear APIs. Legacy `taste` migrates into UserPrefs once.
## Layout
- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`); **Посмотри результат** attaches the finished frame and asks for a verdict
- **Splitter:** drag to resize panes
-- **Right:** Chat | Cards; persona / pack / Ollama chat model; **Ollama health** badge; settings gear (memory model + skills + **Память**)
+- **Right:** Chat | Cards; persona / pack / Ollama chat model; **Ollama health** badge; ⚙ settings panel (6 tabs)
- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
## Config (bundled + overlay)
@@ -26,7 +26,7 @@ Assistent/
_base/ personas// # overlay presets — same names as Config/, sparse
settings.json # embed_model, base_url, per-persona skills
ollama-roles.json # chat vs memory model tags (gpu-rent writes this)
- memory/assistent.sqlite # vector memory + tags FTS + chats + ui_state + taste
+ memory/assistent.sqlite # craft RAG + user_prefs + tags FTS + chats + ui_state + taste (legacy)
_migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json
```
@@ -34,7 +34,7 @@ Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/person
**Controls:** optional `controls.json` schema + `exact.controls` values. UI shows sliders; LLM may patch `"controls": {…}`. Values persist in overlay Exact (not session_exact).
-**Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button (never from the model).
+**Authoring:** pack `author_persona` + `/persona new` clones to overlay only. Delete overlay personas with the ✕ button or ⚙ → Личности (never from the model). Export/import `.assistent-persona.json` for sharing.
## Exact memory (KV)
@@ -42,19 +42,28 @@ Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/person
- Persona / disk overlays merge via DeepMerge (matching keys overwrite)
- Always injected into the system prompt; UI fills **empty** SwarmUI fields from Exact (no LLM call)
- Chat-session overrides (`session_exact`) last until persona change or clear chat — not written to disk
-- Priority: core contract → current user → session_exact → exact (+ persona) → live fields → vector `memory_hits`
+- Priority: core → user message → **About the user** → session_exact → exact (+ persona) → live fields → craft `memory_hits`
-## Vector memory
+## About the user (UserPrefs)
+
+Separate sqlite table `user_prefs` (not craft RAG):
+
+- **Global** — every persona (e.g. “avoid blonde hair”)
+- **Persona** — only the current agent
+- Injected as `## About the user`; strength via `user_prefs_weight` / `user_prefs_max` in `assistant.json` (⚙ → О пользователе)
+- Agent write: `actions: ["user_pref_upsert"]` + `user_prefs: [{key,text,scope}]`
+- Legacy `kv.taste` migrates once into global prefs
+
+## Craft vector memory
Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
- **Personal** — `Config/personas//memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
-- Retrieve = shared ∪ this persona (and `extends` parents). Hybrid **FTS5 + cosine**, kind quotas (e.g. 3 cards / 3 pitfalls / 4 notes), `min_score`. Same `kind`+`key`: personal overwrites parent overwrites shared.
+- Retrieve = shared ∪ this persona (and `extends` parents). Hybrid **FTS5 + cosine**, kind quotas, `min_score`. Same `kind`+`key`: personal overwrites parent overwrites shared.
- Tools: `memory_get`, `memory_search`, `lookup_tags` (Danbooru csv in `Data/Autocompletions`, FTS, **no embeddings**).
-- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
-- Soft notes only — Exact and the user beat RAG for params
-- ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
+- Soft craft notes only — Exact, About the user, and the user beat RAG for params
+- ⚙ → **Крафт** lists rows with filters + clear (bundled seed is read-only)
## Chats and runtime KV
@@ -116,11 +125,11 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
## Packs & skills
-**Packs** (one active): `write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`.
+**Packs** (one active): `write_prompt`, `critique_image`, `compose_scene`, `fix_params`, `inpaint_edit`, `describe_ref`, `catalog_card`, `author_persona`.
-**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures; encyclopedia numbers live in Exact, soft notes in memory-seed / RAG.
+**Skills** (checkboxes): `prompting`, `creativity_sliders`, `memory` — procedures; encyclopedia numbers live in Exact, soft notes in memory-seed / RAG, human taste in UserPrefs.
-**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`, `leonid` under `Config/personas/`. Overlay clones via `/persona new`.
+**Personas:** `neutral`, `lewd`, `aggressive`, `cinema`, `terse`, `leonid` under `Config/personas/`. Overlay clones via `/persona new` or ⚙ → Личности.
## API routes
@@ -133,6 +142,8 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentClonePersona` | Snapshot clone → overlay id |
| `AssistentSavePersona` | Sparse shelf write (overlay only) |
| `AssistentDeletePersona` | UI-only delete of overlay persona |
+| `AssistentExportPersona` / `AssistentImportPersona` | Shareable `.assistent-persona.json` pack |
+| `AssistentSaveKnobs` | Overlay `_base/assistant.json` + Exact turbo/raw profiles |
| `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) |
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
| `AssistentListPersonas` | Persona catalog |
@@ -140,10 +151,11 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
-| `AssistentGetTaste` / `AssistentSaveTaste` | sqlite `kv.taste` |
+| `AssistentGetTaste` / `AssistentSaveTaste` | sqlite `kv.taste` (legacy; prefer UserPrefs) |
+| `AssistentListUserPrefs` / `AssistentUpsertUserPref` / `AssistentForgetUserPref` / `AssistentClearUserPrefs` | About the user |
| `AssistentSearchCivitai` | Civitai LoRA search |
-| `AssistentChat` / `AssistentChatWS` | Chat (+ hybrid memory + Civitai/tag hops) |
-| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
+| `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + hops) |
+| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs
index f055716..fc13a1b 100644
--- a/SwarmAssistentExtension.cs
+++ b/SwarmAssistentExtension.cs
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT";
- Version = "0.9.0";
+ Version = "0.10.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
}
@@ -81,7 +81,15 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentClonePersona, true, PermUse);
API.RegisterAPICall(AssistentSavePersona, true, PermUse);
API.RegisterAPICall(AssistentDeletePersona, true, PermUse);
- Logs.Init("Swarm Assistent extension loaded (persona shelves + controls + overlay clone)");
+ API.RegisterAPICall(AssistentExportPersona, false, PermUse);
+ API.RegisterAPICall(AssistentImportPersona, true, PermUse);
+ API.RegisterAPICall(AssistentSaveKnobs, true, PermUse);
+ API.RegisterAPICall(AssistentListUserPrefs, false, PermUse);
+ API.RegisterAPICall(AssistentUpsertUserPref, true, PermUse);
+ API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
+ API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
+ API.RegisterAPICall(AssistentClearMemory, true, PermUse);
+ Logs.Init("Swarm Assistent extension loaded (settings panel + user prefs + craft memory)");
}
int CfgInt(string key, int fallback)
diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html
index 1acbc6c..b8f0906 100644
--- a/Tabs/Text2Image/Assistent.html
+++ b/Tabs/Text2Image/Assistent.html
@@ -73,33 +73,144 @@
-
-
-
-
-
Скилы (процедуры)
-
-
-
Память
-
-
+
+ Настройки
+
-
-