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