Ship Assistent 0.10: settings panel and UserPrefs with prompt weight.

Separate About-the-user memory (global + per-persona) from craft RAG, add tabbed settings with persona export/import and craft clear APIs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 02:05:12 +03:00
co-authored by Cursor
parent cf89348f85
commit fa73158e1c
18 changed files with 2081 additions and 191 deletions
+165 -1
View File
@@ -729,11 +729,172 @@
} }
.sa-settings { .sa-settings {
display: grid; display: flex;
flex-direction: column;
gap: 0.45rem; gap: 0.45rem;
padding: 0.7rem 0.75rem; padding: 0.7rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 5%, 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 { .sa-settings label {
@@ -744,6 +905,9 @@
} }
.sa-settings input[type="text"], .sa-settings input[type="text"],
.sa-settings input[type="number"],
.sa-settings input[type="search"],
.sa-settings input[type="range"],
.sa-select { .sa-select {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
+715 -42
View File
@@ -160,6 +160,9 @@
slashIndex: 0, slashIndex: 0,
llmParked: false, llmParked: false,
memoryRows: [], memoryRows: [],
userPrefs: [],
settingsTab: 'behavior',
settingsPersonaId: null,
wanted: { count: 0, items: [] }, wanted: { count: 0, items: [] },
wantedKeys: new Set(), wantedKeys: new Set(),
ollamaHealth: 'unknown', ollamaHealth: 'unknown',
@@ -2152,6 +2155,18 @@
fillEmptyParamsFromExact(); fillEmptyParamsFromExact();
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {}); renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source); 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, auto_generate: !!$('sa_auto_generate')?.checked,
persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', persona: $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral',
model_cards: [], model_cards: [],
taste_profile: summarizeTaste(), user_prefs_count: 0,
...initCtx, ...initCtx,
}; };
@@ -2696,6 +2711,34 @@
taste.updated = Date.now(); taste.updated = Date.now();
state.taste = taste; state.taste = taste;
saveTaste(); 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. // 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', 'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise', 'actions', 'search_query', 'civitai_query', 'init_creativity', 'denoise',
'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack', 'look_at', 'vision_from', 'vision_slots', 'aspect', 'batch', 'vary', 'lock_seed', 'pack',
'memories', 'user_prefs',
]; ];
function isPatchObject(obj) { function isPatchObject(obj) {
@@ -4265,6 +4309,7 @@
if (asst.context_prompt_max != null) { if (asst.context_prompt_max != null) {
CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000); CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
} }
fillKnobsFromConfig(data);
if (applyDefaults || data.exact) { if (applyDefaults || data.exact) {
fillEmptyParamsFromExact(); fillEmptyParamsFromExact();
} }
@@ -4537,37 +4582,42 @@
function setModelOptions(models, { error } = {}) { function setModelOptions(models, { error } = {}) {
const sel = $('sa_model'); const sel = $('sa_model');
if (!sel) { const sel2 = $('sa_settings_chat_model');
return; const apply = (target) => {
} if (!target) {
const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean); return;
sel.innerHTML = ''; }
if (error) { const names = (models || []).map((n) => String(n || '').trim()).filter(Boolean);
const opt = document.createElement('option'); target.innerHTML = '';
opt.value = ''; if (error) {
opt.textContent = `${String(error).replace(/\s+/g, ' ').slice(0, 90)}`; const opt = document.createElement('option');
sel.appendChild(opt); opt.value = '';
sel.disabled = true; opt.textContent = `${String(error).replace(/\s+/g, ' ').slice(0, 90)}`;
return; target.appendChild(opt);
} target.disabled = true;
sel.disabled = false; return;
if (!names.length) { }
const opt = document.createElement('option'); target.disabled = false;
opt.value = ''; if (!names.length) {
opt.textContent = 'No Ollama models — pull / Refresh'; const opt = document.createElement('option');
sel.appendChild(opt); opt.value = '';
return; opt.textContent = 'No Ollama models — pull / Refresh';
} target.appendChild(opt);
for (const name of names) { return;
const opt = document.createElement('option'); }
opt.value = name; for (const name of names) {
opt.textContent = name; const opt = document.createElement('option');
sel.appendChild(opt); opt.value = name;
} opt.textContent = name;
const prefer = state.preferredModel || localStorage.getItem(LS_MODEL); target.appendChild(opt);
if (prefer && names.includes(prefer)) { }
sel.value = prefer; const prefer = state.preferredModel || localStorage.getItem(LS_MODEL);
} if (prefer && names.includes(prefer)) {
target.value = prefer;
}
};
apply(sel);
apply(sel2);
} }
function setEmbedModelOptions(models) { function setEmbedModelOptions(models) {
@@ -4691,6 +4741,14 @@
return $('sa_mem_kind')?.value || 'all'; 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) { function renderMemoryKinds(kinds) {
const sel = $('sa_mem_kind'); const sel = $('sa_mem_kind');
if (!sel) { 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() { function renderMemoryList() {
const root = $('sa_mem_list'); const root = $('sa_mem_list');
if (!root) { if (!root) {
return; return;
} }
const filter = memoryKindFilter(); const rows = filteredMemoryRows();
const rows = (state.memoryRows || []).filter((m) => filter === 'all' || m.kind === filter);
root.innerHTML = ''; root.innerHTML = '';
if (!rows.length) { if (!rows.length) {
root.innerHTML = '<div class="sa-mem-empty">Память пуста — она наполняется из карточек, seed и патчей <code>memory_upsert</code>.</div>'; root.innerHTML = '<div class="sa-mem-empty">Крафт-память пуста — карточки, seed и патчи <code>memory_upsert</code>.</div>';
return; return;
} }
for (const row of rows) { 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 = `<div class="sa-persona-item-title"><span class="sa-persona-dot" style="background:${escapeHtml(accent)}"></span><span>${escapeHtml(p.title || p.id)}</span></div><div class="sa-persona-badge">${escapeHtml(personaSourceLabel(p.source))}</div>`;
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 = '<div class="sa-mem-empty">Загрузка…</div>';
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 = `<div class="sa-mem-empty">${escapeHtml(String(err || 'ошибка'))}</div>`;
},
);
}
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 = '<div class="sa-mem-empty">Пусто</div>';
return;
}
for (const row of rows) {
const el = document.createElement('div');
el.className = 'sa-mem-row';
const pin = row.pinned ? ' ★' : '';
el.innerHTML = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-row-key">${escapeHtml(row.key || '')}${pin}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 200))}</div></div>`;
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) { function modelKeyLeaf(name) {
return String(name || '') return String(name || '')
.replace(/\\/g, '/') .replace(/\\/g, '/')
@@ -4877,6 +5486,7 @@
el.title = title || text; el.title = title || text;
el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down'); el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
el.classList.add(`sa-health-${level}`); el.classList.add(`sa-health-${level}`);
syncSettingsHealthLine();
} }
function probeOllamaHealth() { function probeOllamaHealth() {
@@ -5871,7 +6481,7 @@
} else { } else {
why.push('последнего патча Assistent ещё нет'); 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 = [ const lines = [
'### Debug Assistent', '### Debug Assistent',
@@ -6725,18 +7335,75 @@
$('sa_btn_settings')?.addEventListener('click', () => { $('sa_btn_settings')?.addEventListener('click', () => {
const s = $('sa_settings'); const s = $('sa_settings');
if (s) { if (s) {
s.hidden = !s.hidden; if (s.hidden) {
if (!s.hidden) { openSettings(state.settingsTab || 'behavior');
refreshMemoryList(); } else {
refreshWantedQueue(); 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', () => { $('sa_btn_mem_refresh')?.addEventListener('click', () => {
refreshMemoryList(); refreshMemoryList();
refreshWantedQueue(); refreshWantedQueue();
}); });
$('sa_mem_kind')?.addEventListener('change', renderMemoryList); $('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_btn_look_result')?.addEventListener('click', () => askLookAtResult());
$('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth()); $('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
@@ -6746,7 +7413,7 @@
let closed = false; let closed = false;
const settings = $('sa_settings'); const settings = $('sa_settings');
if (settings && !settings.hidden) { if (settings && !settings.hidden) {
settings.hidden = true; closeSettings();
closed = true; closed = true;
} }
if (state.chatsPanelOpen) { if (state.chatsPanelOpen) {
@@ -6854,7 +7521,13 @@
$('sa_board_more_menu')?.addEventListener('click', (e) => e.stopPropagation()); $('sa_board_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_clear_more_menu')?.addEventListener('click', (e) => e.stopPropagation()); $('sa_clear_more_menu')?.addEventListener('click', (e) => e.stopPropagation());
$('sa_base_url')?.addEventListener('change', saveSettings); $('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', () => { $('sa_embed_model')?.addEventListener('change', () => {
state.preferredEmbed = $('sa_embed_model')?.value || ''; state.preferredEmbed = $('sa_embed_model')?.value || '';
saveSettings(); saveSettings();
+2
View File
@@ -14,6 +14,8 @@ window.SA = window.SA || {};
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed', 'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement', 'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack', '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; const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
+90
View File
@@ -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<double?>() ?? 1.0;
int maxPrefs = asst["user_prefs_max"]?.Value<int?>() ?? 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); JObject exact = Config.LoadExactForPrompt(pid);
if (exact is not null && exact.Count > 0) 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); (reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
JObject patch = TryParsePatch(reply); JObject patch = TryParsePatch(reply);
await ApplyMemoryActions(root, patch, embed, pid); await ApplyMemoryActions(root, patch, embed, pid);
ApplyUserPrefActions(patch, pid);
ApplyPersonaActions(patch, ref pid); ApplyPersonaActions(patch, ref pid);
if (hop + 1 >= maxHops) if (hop + 1 >= maxHops)
{ {
@@ -382,6 +403,19 @@ public partial class SwarmAssistentExtension
ctx = new JObject { ["_raw_context"] = contextJson }; ctx = new JObject { ["_raw_context"] = contextJson };
} }
ctx["memory_hits"] = hits ?? new JArray(); 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<double?>() ?? 1.0;
int maxPrefs = asst["user_prefs_max"]?.Value<int?>() ?? 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). // Never re-inject full Exact into live context (already in system prompt).
ctx.Remove("exact"); ctx.Remove("exact");
if (ctx["session_exact"] is null) 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<bool>() == 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}");
}
}
}
} }
+259 -11
View File
@@ -715,6 +715,145 @@ public sealed class AssistentConfig
/// <summary>Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.</summary> /// <summary>Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.</summary>
public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId)); public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId));
/// <summary>DeepMerge sparse keys into Assistent/_base/&lt;fileName&gt; (disk overlay only).</summary>
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;
}
/// <summary>Export a shareable persona pack (merged shelves + controls + personal memory-seed).</summary>
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,
};
}
/// <summary>Import pack into overlay personas/&lt;id&gt;. Never overwrites bundled files.</summary>
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) public JObject LoadModelProfile(string personaId)
{ {
JObject assistant = LoadAssistant(personaId); JObject assistant = LoadAssistant(personaId);
@@ -962,7 +1101,37 @@ public sealed class AssistentConfig
return char.ToUpperInvariant(key[0]) + key[1..]; return char.ToUpperInvariant(key[0]) + key[1..];
} }
public string RenderIdentityBlock(string personaId) static readonly string[] DefaultIdentityAlwaysShelves =
["persona", "voice", "rules", "likes", "dislikes"];
/// <summary>Shelves always injected into the system prompt. Lore shelves load via persona_read hop.</summary>
public HashSet<string> IdentityAlwaysShelves(string personaId)
{
HashSet<string> 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;
}
/// <summary>Render always-on identity (voice/taste). Pass includeAllShelves for author pack / persona_read.</summary>
public string RenderIdentityBlock(string personaId, bool includeAllShelves = false, IEnumerable<string> onlyShelves = null)
{ {
string id = SafeId(personaId) ?? "neutral"; string id = SafeId(personaId) ?? "neutral";
JObject parts = LoadIdentityParts(id); JObject parts = LoadIdentityParts(id);
@@ -976,12 +1145,26 @@ public sealed class AssistentConfig
sb.AppendLine($"*{tagline}*"); sb.AppendLine($"*{tagline}*");
} }
HashSet<string> allow = null;
if (onlyShelves is not null)
{
allow = new HashSet<string>(onlyShelves.Where(s => !string.IsNullOrWhiteSpace(s)), StringComparer.OrdinalIgnoreCase);
}
else if (!includeAllShelves)
{
allow = IdentityAlwaysShelves(id);
}
foreach (JProperty prop in parts.Properties()) foreach (JProperty prop in parts.Properties())
{ {
if (prop.Name is "persona" or "extra") if (prop.Name is "persona" or "extra")
{ {
continue; continue;
} }
if (allow is not null && !allow.Contains(prop.Name))
{
continue;
}
if (prop.Value is not JObject shelf || !shelf.Properties().Any()) if (prop.Value is not JObject shelf || !shelf.Properties().Any())
{ {
continue; continue;
@@ -991,22 +1174,87 @@ public sealed class AssistentConfig
AppendTokenMarkdown(sb, shelf, 0); AppendTokenMarkdown(sb, shelf, 0);
} }
string controlsBlock = RenderControlsBlock(id); // Controls schema/meanings are fat; Exact already carries current values. Only author/full dump.
if (!string.IsNullOrWhiteSpace(controlsBlock)) if (includeAllShelves && onlyShelves is null)
{ {
sb.AppendLine(); string controlsBlock = RenderControlsBlock(id);
sb.AppendLine(controlsBlock); if (!string.IsNullOrWhiteSpace(controlsBlock))
} {
sb.AppendLine();
sb.AppendLine(controlsBlock);
}
string extra = parts["extra"]?.ToString() ?? ""; string extra = parts["extra"]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(extra)) if (!string.IsNullOrWhiteSpace(extra))
{ {
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(extra.Trim()); sb.AppendLine(extra.Trim());
}
} }
return sb.ToString().TrimEnd(); return sb.ToString().TrimEnd();
} }
/// <summary>Markdown for lore shelves not in always-on (persona_read hop).</summary>
public string RenderPersonaReadBlock(string personaId, IEnumerable<string> shelfNames)
{
string id = SafeId(personaId) ?? "neutral";
HashSet<string> always = IdentityAlwaysShelves(id);
List<string> 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<string> PersonaIdsUnder(string root) static IEnumerable<string> PersonaIdsUnder(string root)
{ {
string dir = Path.Combine(root ?? "", "personas"); string dir = Path.Combine(root ?? "", "personas");
+343
View File
@@ -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;
/// <summary>Facts about the human at the desk — global + per-persona, separate from craft RAG.</summary>
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<JObject> rows = [];
string wantScope = (scope ?? "").Trim().ToLowerInvariant();
string wantPersona = AssistentConfig.SafeId(personaId) ?? "";
using SqliteCommand cmd = _conn.CreateCommand();
List<string> 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());
}
}
/// <summary>Select prefs for prompt injection according to weight (0=off, &lt;1=short, 1=full, &gt;1=must).</summary>
public List<JObject> SelectUserPrefsForPrompt(string personaId, double weight, int maxRows)
{
if (weight <= 0)
{
return [];
}
string pid = AssistentConfig.SafeId(personaId) ?? "neutral";
JArray all = ListUserPrefs(null, pid, 500);
List<JObject> list = all.OfType<JObject>().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<JObject> 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<bool>() == true ? " ★" : "";
sb.AppendLine($"- [{sc}] {key}{pin}: {text}");
}
return sb.ToString().TrimEnd();
}
/// <summary>Batch-delete craft vector rows (never bundled). Returns deleted count.</summary>
public int ClearCraftMemory(string scope = null, string kind = null, string persona = null)
{
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
List<string> 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();
}
}
}
+8
View File
@@ -138,6 +138,14 @@ public sealed partial class AssistentMemory : IDisposable
{ {
Logs.Debug($"AssistentMemory store schema: {ex.Message}"); 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; _embedModel = GetMeta("embed_model") ?? _embedModel;
_ = int.TryParse(GetMeta("dims"), out _dims); _ = int.TryParse(GetMeta("dims"), out _dims);
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion); _ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
+33
View File
@@ -242,6 +242,39 @@ public partial class SwarmAssistentExtension
} }
} }
public async Task<JObject> 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}" };
}
}
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary> /// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
public async Task<JObject> AssistentListWanted(Session session) public async Task<JObject> AssistentListWanted(Session session)
{ {
+14 -1
View File
@@ -19,7 +19,8 @@ public partial class SwarmAssistentExtension
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed", "snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
"creativity", "intensity", "complexity", "movement", "creativity", "intensity", "complexity", "movement",
"clear_prompt_images", "slot_to_prompt_image", "pack", "memories", "memory", "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) static bool HasValue(JObject obj, string key)
@@ -146,6 +147,18 @@ public partial class SwarmAssistentExtension
{ {
return "lookup_tags"; 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))) if (ActionsContain(patch, "search_civitai") || !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch)))
{ {
return "civitai"; return "civitai";
+116
View File
@@ -139,4 +139,120 @@ public partial class SwarmAssistentExtension
return new JObject { ["error"] = ex.Message }; return new JObject { ["error"] = ex.Message };
} }
} }
public async Task<JObject> 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<JObject> 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<JObject> 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}" };
}
}
} }
+96
View File
@@ -0,0 +1,96 @@
using System;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>CRUD for About-the-user preferences (global + per-persona).</summary>
public partial class SwarmAssistentExtension
{
public async Task<JObject> 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<JObject> 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<JObject> 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<JObject> 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}" };
}
}
}
+8 -1
View File
@@ -5,14 +5,21 @@
"max_checkpoints_inventory": 60, "max_checkpoints_inventory": 60,
"max_wildcards_inventory": 80, "max_wildcards_inventory": 80,
"inventory_blurb_max": 140, "inventory_blurb_max": 140,
"inventory_prompt_rich": 12,
"inventory_prompt_names": 24,
"inventory_hop_limit": 20,
"max_ref_slots": 4, "max_ref_slots": 4,
"default_pack": "write_prompt", "default_pack": "write_prompt",
"default_persona": "neutral", "default_persona": "neutral",
"embed_model": "nomic-embed-text", "embed_model": "nomic-embed-text",
"memory_top_k": 10, "memory_top_k": 8,
"memory_hit_chars": 240,
"memory_min_score": 0.32, "memory_min_score": 0.32,
"user_prefs_weight": 1.0,
"user_prefs_max": 16,
"max_tool_hops": 4, "max_tool_hops": 4,
"tag_lookup_limit": 20, "tag_lookup_limit": 20,
"identity_always_shelves": ["persona", "voice", "rules", "likes", "dislikes"],
"memory_quotas": { "memory_quotas": {
"card": 3, "card": 3,
"lora": 3, "lora": 3,
+40 -82
View File
@@ -6,110 +6,68 @@ You are **Swarm Assistent**, a collaborative art director for image generation i
When instructions conflict, apply this order (highest wins): 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. 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). 3. **About the user** (`## About the user`) — durable preferences (global + this persona). Respect unless this turn overrides.
4. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged into it. 4. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
5. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change. 5. **Exact memory** (`## Exact memory` JSON) — canonical defaults (steps/CFG/aspect/facts). Persona overlays are already merged.
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 users 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). 6. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
7. Guesses — last resort only. 7. **`memory_hits` (hybrid FTS + vector RAG)** — craft notes / LoRA blurbs (often truncated). Prefer over guesses; never override Exact, About the user, or the users 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 ## 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. - Use only LoRA/checkpoint **names** from `available_loras` / `enabled_loras` (or Civitai hop results). Prefer listed `triggers` / `trigger_phrase` / `blurb` — **never invent**.
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words. - Rich entries (blurbs/triggers) are enabled + top krea-likely. Name-only rows need `list_inventory` + `inventory_query` before you rely on them.
- `session_exact` / `recommended_params` — session overrides and defaults (Exact KV is in the system block above). - `memory_hits` may be truncated (`truncated: true`)use `memory_get` for the full text.
- `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` 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.
- `has_vision_image` — if false, do not invent what the image looks like; emit `look_at` first when you need to see it. - Prefer `krea_likely` / Krea architecture; ignore FLUX/SDXL. Respect current params unless asked or pack is `form_params`.
- `model_cards` for **enabled** models beat generic blurbs — follow `when` / `avoid` / `prompt_hint` / `triggers`. - Init/inpaint flags and `image_slots` are in the JSON. Extra pack fields (aspect, inpaint, persona authoring) are documented in the active pack.
- `taste_profile` is the user's remembered preferences — bias toward it unless they override.
- Prefer `krea_likely` / Krea architecture entries; ignore FLUX/SDXL LoRAs. ## Memory (short)
- 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. - Craft RAG write: `memory_upsert` / `memory_forget` + `memories: [{kind,key,text,scope}]` (default personal).
- **Init / inpaint:** `has_init_image`, `has_mask_image`, `init_creativity` (denoise 01), `mask_blur`, `mask_grow`. - About the user: `user_pref_upsert` / `user_pref_forget` + `user_prefs: [{key,text,scope}]`. Do **not** put human taste into craft `memories`.
- **Board:** `image_slots`. `generate` = live gen. `ref1`… = refs. Emit `look_at` to see an unattached window. - Fat memory skill text: `skill_load` + `skills: ["memory"]` when you need the full write/read playbook.
## Output contract (mandatory) ## Output contract (mandatory)
1. Write a short helpful reply in the user's language (RU or EN). 1. Short helpful reply in the user's language (RU or EN).
2. Then emit **one** fenced JSON patch (only fields you want to change): 2. One fenced JSON patch with **only fields you want to change**:
```json ```json
{ {
"prompt": "...", "prompt": "A fluffy red fox in fresh powder snow, soft morning light, 85mm f/2.8…",
"negative": null, "loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["listed_trigger"]}],
"loras": [{"name": "exact_name_from_list", "weight": 0.8, "triggers": ["..."]}],
"aspect": "16:9", "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"], "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" "notes": "one-line why"
} }
``` ```
### Patch rules ### Patch rules
- Omit keys you are not changing. - Omit unchanged keys. Prefer omitting Exact-matching `steps`/`cfg`/`sigma_shift`/`aspect`.
- 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 full intended set for Apply. Prefer `aspect` over raw width/height.
- `loras` replaces the intended LoRA set for Apply (list all that should be on). - 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.
- Prefer `aspect` over raw width/height when framing changes.
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
- `pack` — switch active prompt pack for a follow-up hop.
- `controls` — only keys declared in this persona's `controls.json` (Exact). Clamp to min/max. Do not invent control ids.
- `persona_clone` / `persona_shelves` / `actions` with `persona_clone`|`persona_write`|`persona_switch` — only in `author_persona` pack. Overlay-only; never delete personas from a patch.
- Do not invent model or LoRA filenames. - 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). - `"generate"` — Apply + start generation when the user wants a new image.
- `"search_civitai"` — Civitai search; user Confirms downloads. - `"search_civitai"` + `search_query` — Civitai hop (user Confirms downloads).
- `"interrupt"` — stop generation. - `"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"` / `"lookup_tags"` — read hops.
- `"memory_get"` / `"memory_search"` — hop: exact row or hybrid search. - `"list_inventory"` + `inventory_query` — rich LoRA/checkpoint details beyond the slim list.
- `"lookup_tags"` — hop: Danbooru csv (aliases/counts). Do not emit tag soup for Krea. - `"skill_load"` + `skills: ["memory"]` — load fat skill text.
- `"persona_clone"` / `"persona_write"` / `"persona_switch"` — author_persona only. Never `"persona_delete"`. - `"persona_read"` — load lore shelves (appearance/outfits/…) not in always-on identity.
- `look_at: ["generate", "ref1"]` — vision hop. - `"memory_upsert"` / `"memory_forget"` / `"user_pref_upsert"` / `"user_pref_forget"` — writes.
- Pure Q&A with no change: omit the JSON patch. - `"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.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"id": "memory", "id": "memory",
"title": "Exact + vector memory", "title": "Exact + vector memory",
"default": true, "default": false,
"prompt_file": "memory.md" "prompt_file": "memory.md"
} }
+16 -8
View File
@@ -1,24 +1,32 @@
# Skill: memory # 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. 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`. 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. **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. 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 ## 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) ## 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. - `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. - `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. 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 agents 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). - Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
- Bad paths / pitfalls you discovered this session. - 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 ## When not to write
- Do not dump Exact defaults or the full inventory into vector memory. - 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 store the user's taste in craft `memories` — use `user_prefs` instead.
- Do not upsert trivia already in `memory_hits`. - 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. - Do not upsert Danbooru tags — the csv catalog already has them.
- `memory_forget` without `scope` only removes the personal overlay. - `memory_forget` without `scope` only removes the personal overlay.
+29 -17
View File
@@ -1,14 +1,14 @@
# Swarm Assistent # 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 ## 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 - **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 - **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) - **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
## Config (bundled + overlay) ## Config (bundled + overlay)
@@ -26,7 +26,7 @@ Assistent/
_base/ personas/<id>/ # overlay presets — same names as Config/, sparse _base/ personas/<id>/ # overlay presets — same names as Config/, sparse
settings.json # embed_model, base_url, per-persona skills settings.json # embed_model, base_url, per-persona skills
ollama-roles.json # chat vs memory model tags (gpu-rent writes this) 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 _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). **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) ## 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) - 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) - 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 - 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): Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
- **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona. - **Shared** — `Config/_base/memory-seed/`, model cards, `scope: "shared"` upserts. Visible to every persona.
- **Personal** — `Config/personas/<id>/memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it. - **Personal** — `Config/personas/<id>/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**). - 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 craft notes only — Exact, About the user, and the user beat RAG for params
- Soft notes only — Exact and the user beat RAG for params - ⚙ → **Крафт** lists rows with filters + clear (bundled seed is read-only)
- ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
## Chats and runtime KV ## Chats and runtime KV
@@ -116,11 +125,11 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
## Packs & skills ## 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 ## API routes
@@ -133,6 +142,8 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentClonePersona` | Snapshot clone → overlay id | | `AssistentClonePersona` | Snapshot clone → overlay id |
| `AssistentSavePersona` | Sparse shelf write (overlay only) | | `AssistentSavePersona` | Sparse shelf write (overlay only) |
| `AssistentDeletePersona` | UI-only delete of overlay persona | | `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) | | `AssistentGetSettings` / `AssistentSaveSettings` | Overlay settings (skills, embed_model) |
| `AssistentListInventory` | LoRA / checkpoint / wildcard inventory | | `AssistentListInventory` | LoRA / checkpoint / wildcard inventory |
| `AssistentListPersonas` | Persona catalog | | `AssistentListPersonas` | Persona catalog |
@@ -140,10 +151,11 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) | | `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash | | `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) | | `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 | | `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` / `AssistentChatWS` | Chat (+ hybrid memory + Civitai/tag hops) | | `AssistentChat` / `AssistentChatWS` | Chat (+ user prefs + hybrid craft memory + hops) |
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) | | `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` / `AssistentClearMemory` | Craft vector store |
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key | | `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) | | `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) | | `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
+10 -2
View File
@@ -36,7 +36,7 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid"; ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT"; License = "MIT";
Version = "0.9.0"; Version = "0.10.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
} }
@@ -81,7 +81,15 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentClonePersona, true, PermUse); API.RegisterAPICall(AssistentClonePersona, true, PermUse);
API.RegisterAPICall(AssistentSavePersona, true, PermUse); API.RegisterAPICall(AssistentSavePersona, true, PermUse);
API.RegisterAPICall(AssistentDeletePersona, 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) int CfgInt(string key, int fallback)
+136 -25
View File
@@ -73,33 +73,144 @@
</div> </div>
</header> </header>
<div class="sa-settings" id="sa_settings" hidden> <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> <div class="sa-settings-head">
<label>Модель памяти <strong>Настройки</strong>
<select id="sa_embed_model" class="sa-select" title="Хранение и группировка памяти (use: memory)"> <button type="button" class="basic-button sa-icon-btn" id="sa_settings_close" title="Закрыть" aria-label="Закрыть"></button>
<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>
<div class="sa-mem-head">
<span class="sa-skills-label">Память</span>
<select id="sa_mem_kind" class="sa-select sa-mem-kind" title="Фильтр по типу">
<option value="all">Все типы</option>
</select>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память и очередь wanted"></button>
</div> </div>
<div class="sa-mem-list" id="sa_mem_list"></div> <div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек">
<div class="sa-mem-foot"> <button type="button" class="sa-stab sa-stab-active" data-stab="behavior" role="tab" aria-selected="true">Поведение</button>
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span> <button type="button" class="sa-stab" data-stab="models" role="tab" aria-selected="false">Модели</button>
<span class="sa-mem-wanted" id="sa_mem_wanted" title="Модели в очереди на следующий gpu-rent up">Очередь wanted: —</span> <button type="button" class="sa-stab" data-stab="personas" role="tab" aria-selected="false">Личности</button>
<button type="button" class="sa-stab" data-stab="user" role="tab" aria-selected="false">О пользователе</button>
<button type="button" class="sa-stab" data-stab="craft" role="tab" aria-selected="false">Крафт</button>
<button type="button" class="sa-stab" data-stab="more" role="tab" aria-selected="false">Ещё</button>
</div>
<div class="sa-settings-panes">
<div class="sa-spane" data-spane="behavior">
<p class="sa-settings-hint">Автодействия после ответа модели и скилы текущей личности.</p>
<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>
<label class="sa-check"><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label>
<label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label>
<div class="sa-skills-label">Скилы (процедуры)</div>
<div class="sa-skills-box" id="sa_skills_box"></div>
</div>
<div class="sa-spane" data-spane="models" hidden>
<p class="sa-settings-hint">Ollama и модель эмбеддингов для крафт-памяти.</p>
<label>Ollama URL <input type="text" id="sa_base_url" value="http://127.0.0.1:11434" /></label>
<label>Модель чата
<select id="sa_settings_chat_model" class="sa-select" title="Синхрон с шапкой">
<option value=""></option>
</select>
</label>
<label>Модель памяти (embed)
<select id="sa_embed_model" class="sa-select" title="Хранение и группировка памяти (use: memory)">
<option value="nomic-embed-text">nomic-embed-text</option>
</select>
</label>
<div class="sa-settings-row">
<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>
<button type="button" class="basic-button" id="sa_btn_settings_health">Проверить Ollama</button>
</div>
<div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div>
</div>
<div class="sa-spane" data-spane="personas" hidden>
<p class="sa-settings-hint">Встроенные личности нельзя удалить. Свои — экспорт / импорт / удаление.</p>
<div class="sa-persona-panel">
<div class="sa-persona-list" id="sa_persona_list"></div>
<div class="sa-persona-preview" id="sa_persona_preview">
<div class="sa-mem-empty">Выбери личность слева</div>
</div>
</div>
<div class="sa-settings-row">
<button type="button" class="basic-button" id="sa_btn_persona_export">Экспорт</button>
<button type="button" class="basic-button" id="sa_btn_persona_import">Импорт…</button>
<button type="button" class="basic-button" id="sa_btn_persona_clone">Клонировать</button>
<button type="button" class="basic-button sa-danger-btn" id="sa_btn_persona_delete_panel" disabled title="Только overlay">Удалить</button>
<input type="file" id="sa_persona_import_file" accept=".json,.assistent-persona.json,application/json" hidden />
</div>
</div>
<div class="sa-spane" data-spane="user" hidden>
<p class="sa-settings-hint">Факты о тебе: общие для всех агентов и личные для текущей личности. Вес управляет силой в промпте.</p>
<label>Вес в промпте <span id="sa_user_prefs_weight_val">1.0</span>
<input type="range" id="sa_user_prefs_weight" min="0" max="1.5" step="0.1" value="1" />
</label>
<p class="sa-settings-hint">0 = выкл · 0.5 = кратко · 1 = полный список · &gt;1 = жёстко соблюдать</p>
<div class="sa-user-prefs-cols">
<div>
<div class="sa-mem-head">
<span class="sa-skills-label">Общие</span>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_pref_add_global" title="Добавить">+</button>
</div>
<div class="sa-mem-list" id="sa_prefs_global"></div>
</div>
<div>
<div class="sa-mem-head">
<span class="sa-skills-label">Для этой личности</span>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_pref_add_persona" title="Добавить">+</button>
</div>
<div class="sa-mem-list" id="sa_prefs_persona"></div>
</div>
</div>
<div class="sa-settings-row">
<button type="button" class="basic-button" id="sa_btn_prefs_refresh">Обновить</button>
<button type="button" class="basic-button" id="sa_btn_prefs_clear_global">Очистить общие</button>
<button type="button" class="basic-button" id="sa_btn_prefs_clear_persona">Очистить личность</button>
<button type="button" class="basic-button sa-danger-btn" id="sa_btn_prefs_clear_all">Очистить всё</button>
</div>
</div>
<div class="sa-spane" data-spane="craft" hidden>
<p class="sa-settings-hint">Крафт-RAG: LoRA, карточки, pitfalls. Не путать с «О пользователе».</p>
<div class="sa-mem-head">
<span class="sa-skills-label">Крафт-память</span>
<select id="sa_mem_scope" class="sa-select sa-mem-kind" title="Слой">
<option value="all">Все слои</option>
<option value="shared">Общая</option>
<option value="personal">Эта личность</option>
</select>
<select id="sa_mem_kind" class="sa-select sa-mem-kind" title="Фильтр по типу">
<option value="all">Все типы</option>
</select>
<input type="search" id="sa_mem_search" class="sa-mem-search" placeholder="Поиск…" autocomplete="off" />
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память и очередь wanted"></button>
</div>
<div class="sa-mem-list" id="sa_mem_list"></div>
<div class="sa-mem-foot">
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span>
<span class="sa-mem-wanted" id="sa_mem_wanted" title="Модели в очереди на следующий gpu-rent up">Очередь wanted: —</span>
</div>
<label>memory_top_k <input type="number" id="sa_memory_top_k" min="1" max="30" step="1" value="10" /></label>
<div class="sa-settings-row">
<button type="button" class="basic-button" id="sa_btn_mem_clear_kind">Очистить тип</button>
<button type="button" class="basic-button" id="sa_btn_mem_clear_persona">Очистить личность</button>
<button type="button" class="basic-button" id="sa_btn_mem_clear_shared">Очистить общую</button>
<button type="button" class="basic-button sa-danger-btn" id="sa_btn_mem_clear_all">Очистить всё крафт</button>
</div>
</div>
<div class="sa-spane" data-spane="more" hidden>
<p class="sa-settings-hint">Контекст Ollama и Exact Turbo/RAW (пишется в overlay).</p>
<label>num_ctx <input type="number" id="sa_num_ctx" min="2048" max="131072" step="1024" value="16384" /></label>
<label>history_keep_turns <input type="number" id="sa_history_keep" min="1" max="32" step="1" value="4" /></label>
<div class="sa-skills-label">Exact · Turbo</div>
<div class="sa-settings-row sa-knob-row">
<label>steps <input type="number" id="sa_exact_turbo_steps" min="1" max="64" step="1" /></label>
<label>cfg <input type="number" id="sa_exact_turbo_cfg" min="0.1" max="30" step="0.1" /></label>
<label>σ <input type="number" id="sa_exact_turbo_sigma" min="0.1" max="10" step="0.05" /></label>
</div>
<div class="sa-skills-label">Exact · RAW</div>
<div class="sa-settings-row sa-knob-row">
<label>steps <input type="number" id="sa_exact_raw_steps" min="1" max="64" step="1" /></label>
<label>cfg <input type="number" id="sa_exact_raw_cfg" min="0.1" max="30" step="0.1" /></label>
<label>σ <input type="number" id="sa_exact_raw_sigma" min="0.1" max="10" step="0.05" /></label>
</div>
<div class="sa-settings-row">
<button type="button" class="basic-button sa-primary" id="sa_btn_knobs_save">Сохранить knobs</button>
<button type="button" class="basic-button" id="sa_btn_reset_ui">Сброс UI-state</button>
</div>
</div>
</div> </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>
<label class="sa-check"><input type="checkbox" id="sa_auto_critique" /> Авто-критика после Generate</label>
<label class="sa-check sa-danger" title="Опасно — скачивает без Confirm"><input type="checkbox" id="sa_auto_download" /> Авто-скачивание Civitai (выкл)</label>
</div> </div>
<div class="sa-view" id="sa_view_chat"> <div class="sa-view" id="sa_view_chat">