Ship Assistent 0.8.1: split modules and shared+personal vector memory.

Personal RAG never leaks into the shared store; retrieve merges shared plus the persona chain, with personal overwrite on kind+key.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 00:38:54 +03:00
co-authored by Cursor
parent 6d8aaefc38
commit 880e2dbea2
21 changed files with 3946 additions and 2040 deletions
+27
View File
@@ -0,0 +1,27 @@
/**
* Swarm Assistent — promise wrapper around SwarmUI's genericRequest.
* Loaded before assistent.js.
*/
window.SA = window.SA || {};
SA.request = function (name, body) {
return new Promise((resolve, reject) => {
if (typeof genericRequest !== 'function') {
reject(new Error('genericRequest unavailable'));
return;
}
genericRequest(
name,
body || {},
(data) => {
if (data && data.error) {
reject(new Error(String(data.error)));
} else {
resolve(data);
}
},
0,
(err) => reject(err instanceof Error ? err : new Error(String(err || 'request failed'))),
);
});
};
+126 -73
View File
@@ -84,16 +84,6 @@
opacity: 0.9;
}
.sa-board-hint {
flex: 1;
font-size: 0.75rem;
opacity: 0.55;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sa-board {
flex: 1;
display: grid;
@@ -247,43 +237,6 @@
opacity: 0.9;
}
.sa-image-frame {
position: relative;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed color-mix(in srgb, currentColor 28%, transparent);
border-radius: 0.55rem;
overflow: hidden;
background:
radial-gradient(ellipse at 30% 20%, color-mix(in srgb, currentColor 8%, transparent), transparent 55%),
color-mix(in srgb, currentColor 4%, transparent);
min-height: 14rem;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.sa-image-frame:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 35%, transparent);
}
.sa-image-frame.sa-has-image {
border-style: solid;
}
.sa-image-frame.sa-dragover {
border-color: color-mix(in srgb, currentColor 70%, transparent);
box-shadow: inset 0 0 0 2px color-mix(in srgb, currentColor 25%, transparent);
}
.sa-image-frame img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
}
.sa-image-empty {
padding: 1.25rem 1rem;
text-align: center;
@@ -302,32 +255,6 @@
opacity: 0.9;
}
.sa-drop-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, currentColor 18%, transparent);
font-weight: 600;
letter-spacing: 0.04em;
pointer-events: none;
}
.sa-vision-chip {
position: absolute;
top: 0.45rem;
left: 0.45rem;
padding: 0.15rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: color-mix(in srgb, currentColor 16%, transparent);
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
}
.sa-image-actions {
display: flex;
flex-wrap: wrap;
@@ -786,6 +713,132 @@
margin-bottom: 0.25rem;
}
.sa-mem-head {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
}
.sa-mem-head .sa-skills-label {
flex: 1;
margin-top: 0;
}
.sa-settings .sa-mem-kind {
width: auto;
min-width: 8rem;
max-width: 12rem;
}
.sa-mem-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 14rem;
overflow: auto;
padding: 0.25rem;
border-radius: 0.4rem;
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
background: color-mix(in srgb, currentColor 4%, transparent);
}
.sa-mem-empty {
font-size: 0.8rem;
opacity: 0.65;
padding: 0.35rem 0.25rem;
}
.sa-mem-row {
display: flex;
align-items: flex-start;
gap: 0.35rem;
padding: 0.3rem 0.35rem;
border-radius: 0.35rem;
background: color-mix(in srgb, currentColor 4%, transparent);
}
.sa-mem-row-body {
flex: 1;
min-width: 0;
}
.sa-mem-row-head {
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.sa-mem-kind-badge {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.55;
}
.sa-mem-row-key {
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sa-mem-row-text {
font-size: 0.76rem;
opacity: 0.78;
line-height: 1.3;
}
.sa-mem-row-meta {
font-size: 0.68rem;
opacity: 0.5;
}
.sa-mem-forget {
flex: 0 0 auto;
padding: 0.1rem 0.4rem !important;
font-size: 0.85rem;
}
.sa-mem-foot {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.35rem;
font-size: 0.72rem;
opacity: 0.7;
}
.sa-health {
font-size: 0.7rem;
padding: 0.05rem 0.4rem;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
cursor: pointer;
white-space: nowrap;
opacity: 0.85;
}
.sa-health-ok {
color: color-mix(in srgb, #6ee7a8 75%, currentColor);
border-color: color-mix(in srgb, #6ee7a8 40%, transparent);
}
.sa-health-warn {
color: color-mix(in srgb, #e3b341 80%, currentColor);
border-color: color-mix(in srgb, #e3b341 40%, transparent);
}
.sa-health-down {
color: color-mix(in srgb, #f2777a 80%, currentColor);
border-color: color-mix(in srgb, #f2777a 45%, transparent);
}
.sa-card-row-wanted {
border-color: color-mix(in srgb, #e3b341 40%, transparent);
}
.sa-settings .sa-select {
width: 100%;
}
+597 -88
View File
@@ -1,6 +1,6 @@
/**
* Swarm Assistent Krea 2 collaborative chat (Ollama via Swarm API).
* v0.7.6: Cheap-bug pass — abort on clear/new, busy until side-effects, interrupt cleans stream.
* v0.8.0: Split assets SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
*/
(function () {
const LS_BASE = 'swarm_assistent_base_url';
@@ -24,8 +24,9 @@
const MAX_CHAT_MSGS = 24;
const TAB_BUTTON_ID = 'maintab_assistent';
const GEN_ID = 'generate';
const MAX_REF_SLOTS = 4;
const CONTEXT_PROMPT_MAX = 2000;
let MAX_REF_SLOTS = 4;
let CONTEXT_PROMPT_MAX = 2000;
let HISTORY_KEEP_TURNS = 4;
let ASPECT_TABLE = {
'1:1': [1024, 1024],
@@ -155,8 +156,18 @@
restoringChat: false,
chatsPanelOpen: false,
slashIndex: 0,
llmParked: false,
memoryRows: [],
wanted: { count: 0, items: [] },
wantedKeys: new Set(),
ollamaHealth: 'unknown',
};
/** Disk persistence module (assistent.persist.js) — absent means localStorage only. */
function diskPersist() {
return (window.SA && window.SA.persist) || null;
}
function $(id) {
return document.getElementById(id);
}
@@ -215,6 +226,7 @@
thinking: 'Thinking…',
streaming: 'Writing…',
generating: 'Generating image…',
parking: 'Освобождаю VRAM (park LLM)…',
applying: 'Applying patch…',
silent_gen: 'Применяю патч → Generate…',
refining: 'Civitai search done — refining…',
@@ -676,6 +688,7 @@
const tab = document.getElementById(TAB_BUTTON_ID);
if (tab) {
tab.click();
setTimeout(() => $('sa_input')?.focus(), 50);
return true;
}
const pane = document.getElementById('assistent');
@@ -684,9 +697,15 @@
bootstrap.Tab.getOrCreateInstance(tab || pane).show();
} catch (e) { /* ignore */ }
}
setTimeout(() => $('sa_input')?.focus(), 50);
return !!tab;
}
function historyMessageLimit() {
const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
return turns * 2;
}
function flashImagePane(slotId) {
const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
if (!el) {
@@ -1540,6 +1559,7 @@
}));
state.chats = chats;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
saveActiveChatToDisk();
} catch (e) {
console.warn('Assistent: persist chats failed', e);
try {
@@ -1550,19 +1570,33 @@
.slice(0, 12)
.map((c) => ({
...c,
messages: slimHistoryMessages(c.messages).slice(-12).map((m) => ({
messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
...m,
content: String(m.content || '').slice(0, 1500),
})),
}));
state.chats = slim;
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
saveActiveChatToDisk();
} catch (e2) {
console.warn('Assistent: chats quota fallback failed', e2);
}
}
}
/** Mirrors the active chat onto the data volume (debounced inside SA.persist). */
function saveActiveChatToDisk() {
const persist = diskPersist();
if (!persist || !state.activeChatId) {
return;
}
const chat = findChat(state.activeChatId);
if (!chat || !(chat.messages || []).length) {
return;
}
persist.saveChat(chat);
}
function loadChatsStore() {
state.chats = [];
try {
@@ -1577,6 +1611,28 @@
migrateLegacyHistoryIntoChats();
}
/** Disk wins over localStorage — chats follow the data volume, not the browser. */
async function loadChatsFromDisk() {
const persist = diskPersist();
if (!persist) {
return;
}
let chats = null;
try {
chats = await persist.loadChats();
} catch (e) {
console.warn('Assistent: disk chats failed', e);
return;
}
if (!Array.isArray(chats) || !chats.length) {
return;
}
state.chats = chats.filter((c) => c && c.id).slice(0, MAX_CHATS);
try {
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: state.chats }));
} catch (e) { /* quota — disk is the source of truth anyway */ }
}
function migrateLegacyHistoryIntoChats() {
try {
const raw = localStorage.getItem(LS_HISTORY);
@@ -1862,6 +1918,7 @@
if (wasActive) {
state.activeChatId = null;
}
diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
persistChatsStore();
syncHistoryBadge();
if (wasActive) {
@@ -1871,11 +1928,13 @@
}
}
function initChatSessions() {
async function initChatSessions() {
loadChatsStore();
await loadChatsFromDisk();
// Always open a fresh chat on startup; past chats stay in History.
startNewChat({ saveCurrent: false, force: true });
syncHistoryBadge();
renderChatsList();
}
function persistHistory() {
@@ -2588,51 +2647,21 @@
saveTaste();
}
// Fallback key list — only used if assistent.patch.js failed to load.
const FALLBACK_PATCH_KEYS = [
'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',
];
function isPatchObject(obj) {
if (window.SA && typeof SA.isPatchObject === 'function') {
return SA.isPatchObject(obj);
}
if (!obj || typeof obj !== 'object') {
return false;
}
return (
obj.prompt != null ||
obj.loras ||
obj.width ||
obj.height ||
obj.steps ||
obj.cfg ||
obj.seed != null ||
obj.sigma_shift != null ||
obj.sampler ||
obj.actions ||
obj.search_query ||
obj.civitai_query ||
obj.use_init_image != null ||
obj.clear_init_image != null ||
obj.init_creativity != null ||
obj.denoise != null ||
obj.use_mask_image != null ||
obj.clear_mask_image != null ||
obj.mask_blur != null ||
obj.mask_grow != null ||
obj.look_at != null ||
obj.vision_from != null ||
obj.vision_slots != null ||
obj.slot_to_init != null ||
obj.slot_to_mask != null ||
obj.snapshot_generate != null ||
obj.select_slot != null ||
obj.aspect != null ||
obj.images != null ||
obj.batch != null ||
obj.vary != null ||
obj.lock_seed != null ||
obj.creativity != null ||
obj.intensity != null ||
obj.complexity != null ||
obj.movement != null ||
obj.clear_prompt_images != null ||
obj.slot_to_prompt_image != null ||
obj.pack != null
);
return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
}
function isCardObject(obj) {
@@ -2676,6 +2705,9 @@
}
function extractPatch(text) {
if (window.SA && typeof SA.extractPatch === 'function') {
return SA.extractPatch(text);
}
if (!text) {
return { prose: text || '', patch: null };
}
@@ -2684,9 +2716,8 @@
let lastPatch = null;
let prose = text;
while ((match = re.exec(text)) !== null) {
const raw = match[1].trim();
try {
const obj = JSON.parse(raw);
const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) {
lastPatch = obj;
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
@@ -3169,6 +3200,42 @@
return false;
}
/** Unloads the chat model from VRAM so Krea 2 gets the whole GPU. Never touches the embed model. */
function parkLlm() {
return new Promise((resolve) => {
const model = $('sa_model')?.value;
if (!model || state.llmParked || typeof genericRequest !== 'function') {
resolve(false);
return;
}
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
let settled = false;
const finish = (ok) => {
if (settled) {
return;
}
settled = true;
if (ok) {
state.llmParked = true;
}
resolve(!!ok);
};
setTimeout(() => finish(false), 8000);
genericRequest('AssistentParkLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
});
}
/** Fire-and-forget re-load of the chat model once the user is back in the chat. */
function warmLlm() {
const model = $('sa_model')?.value;
if (!model || !state.llmParked || typeof genericRequest !== 'function') {
return;
}
state.llmParked = false;
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => {}, 0, () => {});
}
function cancelWaitForNewImage() {
if (state.waitImageTimer) {
clearInterval(state.waitImageTimer);
@@ -3259,6 +3326,9 @@
return null;
}
const prev = findCurrentGenerateSrc();
startBusyUi('parking');
setStatus('Освобождаю VRAM…');
await parkLlm();
setStatus('Генерация…');
startBusyUi('generating');
state.generating = true;
@@ -3276,6 +3346,9 @@
if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
}
if (state.view === 'chat') {
warmLlm();
}
if (src) {
const gen = generateSlot();
if (gen) {
@@ -3291,31 +3364,77 @@
return null;
}
/** Resolves the freshest real Generate frame — never a model preview. */
async function resolveFinishedGenerateSrc(hint, { settleMs = 20000 } = {}) {
scrubPreviewFromGenerateSlot();
let src = hint && !looksLikeModelPreview(hint) ? hint : null;
if (!src) {
src = findCurrentGenerateSrc();
}
// Batch still running: the last frame is not the final one yet.
if (isGenerateUnavailable()) {
const settled = await waitForNewImage(src, settleMs);
if (settled) {
src = settled;
}
}
return src && !looksLikeModelPreview(src) ? src : null;
}
async function maybeAutoCritique(imageSrc) {
if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed || !imageSrc) {
if (!$('sa_auto_critique')?.checked || state.critiqueHopUsed) {
return;
}
const src = await resolveFinishedGenerateSrc(imageSrc);
if (!src) {
setStatus('Авто-критика пропущена — нет готового кадра Generate');
return;
}
state.critiqueHopUsed = true;
const pack = $('sa_pack');
if (pack) {
pack.value = 'critique_image';
saveSettings();
}
setPackValue('critique_image', { flash: true });
if ($('sa_input')) {
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
}
const gen = generateSlot();
if (gen) {
gen.attach = true;
if (imageSrc) {
gen.src = imageSrc;
}
gen.src = src;
renderBoard();
}
setStatus('Auto-critique…');
await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
}
/** Board action: attach the finished Generate frame and ask for a verdict. */
async function askLookAtResult() {
if (state.busy || state.generating) {
setStatus('Занято — дождись конца ответа или Стоп');
return;
}
if (!updateGate()) {
setStatus('Выбери модель Krea 2');
return;
}
const src = await resolveFinishedGenerateSrc(generateSlot()?.src, { settleMs: 8000 });
if (!src) {
setStatus('Нет готового кадра Generate — сначала сгенерируй');
return;
}
const gen = generateSlot();
if (gen) {
gen.src = src;
gen.attach = true;
}
setBoardTab('generate');
renderBoard();
setView('chat');
setPackValue('critique_image', { flash: true });
if ($('sa_input')) {
$('sa_input').value = 'Посмотри результат: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.';
}
await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true });
}
function currentPersonaInfo() {
const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
const known = (state.personas || []).find((p) => p && p.id === id);
@@ -3505,18 +3624,18 @@
actions.className = 'sa-civitai-actions';
if (r.already_installed) {
const note = document.createElement('span');
note.textContent = 'Installed';
note.textContent = 'Уже установлена';
actions.appendChild(note);
} else if (r.download_url) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'basic-button sa-primary';
btn.textContent = 'Confirm download';
btn.textContent = 'Подтвердить скачивание';
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
actions.appendChild(btn);
} else {
const note = document.createElement('span');
note.textContent = 'No download URL';
note.textContent = 'Нет URL скачивания';
actions.appendChild(note);
}
card.appendChild(actions);
@@ -3531,9 +3650,9 @@
}
if (btn) {
btn.disabled = true;
btn.textContent = 'Downloading…';
btn.textContent = 'Скачиваю…';
}
setStatus(`Downloading ${card.file_name || card.name}`);
setStatus(`Скачиваю ${card.file_name || card.name}`);
setInterruptVisible(true);
const payload = {
url: card.download_url,
@@ -3543,9 +3662,9 @@
const onDone = (ok, msg) => {
setInterruptVisible(state.busy || state.generating);
if (ok) {
setStatus(`Downloaded ${payload.name}`);
setStatus(`Скачано ${payload.name}`);
if (btn) {
btn.textContent = 'Downloaded';
btn.textContent = 'Скачано';
}
refreshInventory(async () => {
await maybeWriteCardAfterDownload({
@@ -3555,12 +3674,12 @@
});
}, { rescan: true });
} else {
setStatus(msg || 'Download failed');
setStatus(msg || 'Ошибка скачивания');
if (btn) {
btn.disabled = false;
btn.textContent = 'Confirm download';
btn.textContent = 'Подтвердить скачивание';
}
appendMessage('error', msg || 'Download failed');
appendMessage('error', msg || 'Ошибка скачивания');
}
};
if (typeof makeWSRequest === 'function') {
@@ -3858,6 +3977,88 @@
}
}
function collectUiState() {
return {
pack: $('sa_pack')?.value || 'write_prompt',
persona: $('sa_persona')?.value || 'neutral',
auto_vision: !!$('sa_auto_vision')?.checked,
auto_apply: !!$('sa_auto_apply')?.checked,
auto_generate: !!$('sa_auto_generate')?.checked,
auto_critique: !!$('sa_auto_critique')?.checked,
auto_download: !!$('sa_auto_download')?.checked,
pane_width: localStorage.getItem(LS_PANE_WIDTH) || '',
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
base_url: $('sa_base_url')?.value || '',
model: $('sa_model')?.value || '',
view: state.view || 'chat',
board_tab: state.boardTab || 'generate',
};
}
/**
* Fills fields the browser has never seen from Assistent/ui-state.json, so a fresh
* browser on the same volume inherits the previous session. Existing localStorage wins.
* auto_download is only ever restored when it is off the danger flag stays opt-in.
*/
async function applyDiskUiState() {
const persist = diskPersist();
if (!persist) {
return;
}
let ui = null;
try {
ui = await persist.loadUiState();
} catch (e) {
return;
}
if (!ui || typeof ui !== 'object') {
return;
}
const fill = (lsKey, value, apply) => {
if (value == null || value === '' || localStorage.getItem(lsKey) != null) {
return;
}
localStorage.setItem(lsKey, String(value));
apply?.(String(value));
};
fill(LS_BASE, ui.base_url, (v) => { if ($('sa_base_url')) { $('sa_base_url').value = v; } });
fill(LS_MODEL, ui.model, (v) => { state.preferredModel = v; });
fill(LS_EMBED, ui.embed_model, (v) => { state.preferredEmbed = v; });
fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
if (ui.view === 'cards' || ui.view === 'chat') {
fill(LS_VIEW, ui.view, (v) => { state.view = v; });
}
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
}
for (const [key, lsKey, id] of [
['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
['auto_generate', LS_AUTO_GENERATE, 'sa_auto_generate'],
['auto_critique', LS_AUTO_CRITIQUE, 'sa_auto_critique'],
['auto_download', LS_AUTO_DOWNLOAD, 'sa_auto_download'],
]) {
if (ui[key] == null || localStorage.getItem(lsKey) != null) {
continue;
}
const on = ui[key] === true || ui[key] === '1' || ui[key] === 1;
if (on && key === 'auto_download') {
continue;
}
localStorage.setItem(lsKey, on ? '1' : '0');
const el = $(id);
if (el) {
el.checked = on;
}
}
}
function saveUiStateToDisk() {
diskPersist()?.saveUiState(collectUiState());
}
function saveSettings() {
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
localStorage.setItem(LS_MODEL, $('sa_model')?.value || '');
@@ -3871,6 +4072,7 @@
localStorage.setItem(LS_AUTO_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
persistServerSettings();
saveUiStateToDisk();
}
function persistServerSettings() {
@@ -3936,6 +4138,16 @@
if (data.assistant?.embed_model && !state.preferredEmbed) {
state.preferredEmbed = data.assistant.embed_model;
}
const asst = data.assistant || {};
if (asst.history_keep_turns != null) {
HISTORY_KEEP_TURNS = Math.max(1, Number(asst.history_keep_turns) || 4);
}
if (asst.max_ref_slots != null) {
MAX_REF_SLOTS = Math.max(1, Number(asst.max_ref_slots) || 4);
}
if (asst.context_prompt_max != null) {
CONTEXT_PROMPT_MAX = Math.max(200, Number(asst.context_prompt_max) || 2000);
}
if (applyDefaults || data.exact) {
fillEmptyParamsFromExact();
}
@@ -4147,6 +4359,11 @@
$('sa_model').value = prefer;
}
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
if (models.length) {
setOllamaHealth('ok', `Ollama · ${models.length}`, `Чат-моделей: ${models.length}, память: ${memoryModels.length}`);
} else {
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
}
saveSettings();
},
0,
@@ -4154,6 +4371,7 @@
const msg = String(err || 'Ollama unreachable');
setStatus(msg);
setModelOptions([], { error: msg });
setOllamaHealth('down', 'Ollama ✕', msg);
appendMessage('error', msg);
},
);
@@ -4205,6 +4423,224 @@
return new Promise((resolve) => refreshInventory(resolve, opts));
}
function memoryKindFilter() {
return $('sa_mem_kind')?.value || 'all';
}
function renderMemoryKinds(kinds) {
const sel = $('sa_mem_kind');
if (!sel) {
return;
}
const cur = sel.value || 'all';
sel.innerHTML = '';
const all = document.createElement('option');
all.value = 'all';
all.textContent = 'Все типы';
sel.appendChild(all);
for (const kind of kinds || []) {
const opt = document.createElement('option');
opt.value = kind;
opt.textContent = kind;
sel.appendChild(opt);
}
if ([...sel.options].some((o) => o.value === cur)) {
sel.value = cur;
}
}
function renderMemoryList() {
const root = $('sa_mem_list');
if (!root) {
return;
}
const filter = memoryKindFilter();
const rows = (state.memoryRows || []).filter((m) => filter === 'all' || m.kind === filter);
root.innerHTML = '';
if (!rows.length) {
root.innerHTML = '<div class="sa-mem-empty">Память пуста — она наполняется из карточек, seed и патчей <code>memory_upsert</code>.</div>';
return;
}
for (const row of rows) {
const el = document.createElement('div');
el.className = 'sa-mem-row';
const bundled = row.source === 'bundled';
const when = row.updated ? formatChatWhen(row.updated * 1000) : '';
const scope = row.scope === 'personal' ? `персона ${row.persona || '—'}` : 'общая';
el.innerHTML = `<div class="sa-mem-row-body"><div class="sa-mem-row-head"><span class="sa-mem-kind-badge">${escapeHtml(row.kind || 'note')}</span><span class="sa-mem-row-key">${escapeHtml(row.key || '')}</span></div><div class="sa-mem-row-text">${escapeHtml(clipDebug(row.text, 220))}</div><div class="sa-mem-row-meta">${escapeHtml([scope, row.source || 'user', when].filter(Boolean).join(' · '))}</div></div>`;
const forget = document.createElement('button');
forget.type = 'button';
forget.className = 'basic-button sa-mem-forget';
forget.textContent = '×';
if (bundled) {
forget.disabled = true;
forget.title = 'Bundled — вернётся при reseed, правь Config/_base/memory-seed/';
} else {
forget.title = 'Забыть';
forget.addEventListener('click', () => forgetMemory(row));
}
el.appendChild(forget);
root.appendChild(el);
}
}
function refreshMemoryList() {
if (typeof genericRequest !== 'function') {
return;
}
const list = $('sa_mem_list');
if (list && !state.memoryRows.length) {
list.innerHTML = '<div class="sa-mem-empty">Читаю память…</div>';
}
genericRequest(
'AssistentListMemory',
{ limit: 200 },
(data) => {
state.memoryRows = Array.isArray(data?.memories) ? data.memories : [];
renderMemoryKinds(data?.kinds || []);
renderMemoryList();
const foot = $('sa_mem_total');
if (foot) {
foot.textContent = `Всего: ${data?.total ?? state.memoryRows.length} · ${data?.embed_model || '—'}`;
}
},
0,
(err) => {
if (list) {
list.innerHTML = `<div class="sa-mem-empty">Память недоступна: ${escapeHtml(String(err || 'ошибка'))}</div>`;
}
},
);
}
function forgetMemory(row) {
if (!row?.kind || !row?.key || typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentForgetMemory',
{
kind: row.kind,
key: row.key,
source: row.source || '',
scope: row.scope || '',
persona: row.scope === 'personal' ? (row.persona || '') : '',
},
() => {
state.memoryRows = (state.memoryRows || []).filter((m) => !(m.kind === row.kind && m.key === row.key && m.source === row.source && m.persona === row.persona));
renderMemoryList();
setStatus(`Забыто: ${row.kind}/${row.key}`);
},
0,
(err) => setStatus(String(err || 'Не удалось забыть')),
);
}
function modelKeyLeaf(name) {
return String(name || '')
.replace(/\\/g, '/')
.split('/')
.pop()
.replace(/\.(safetensors|ckpt|pt|pth|gguf|bin)$/i, '')
.trim()
.toLowerCase();
}
function refreshWantedQueue() {
if (typeof genericRequest !== 'function') {
return;
}
genericRequest(
'AssistentListWanted',
{},
(data) => {
const items = Array.isArray(data?.items) ? data.items : [];
state.wanted = { count: data?.count ?? items.length, items };
const keys = new Set();
for (const item of items) {
const leaf = modelKeyLeaf(item?.title);
if (leaf) {
keys.add(leaf);
}
if (item?.version_id) {
keys.add(`v${item.version_id}`);
}
}
state.wantedKeys = keys;
const el = $('sa_mem_wanted');
if (el) {
el.textContent = state.wanted.count
? `Очередь wanted: ${state.wanted.count} (скачается на следующем up)`
: 'Очередь wanted: пусто';
el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join('\n');
}
if (state.view === 'cards') {
renderCardsList();
}
},
0,
() => {
const el = $('sa_mem_wanted');
if (el) {
el.textContent = 'Очередь wanted: —';
}
},
);
}
function isWantedModel(row) {
const keys = state.wantedKeys;
if (!keys || !keys.size) {
return false;
}
for (const candidate of [row?.name, row?.title]) {
const leaf = modelKeyLeaf(candidate);
if (leaf && keys.has(leaf)) {
return true;
}
}
return false;
}
function setOllamaHealth(level, text, title) {
state.ollamaHealth = level;
const el = $('sa_ollama_health');
if (!el) {
return;
}
el.hidden = false;
el.textContent = text;
el.title = title || text;
el.classList.remove('sa-health-ok', 'sa-health-warn', 'sa-health-down');
el.classList.add(`sa-health-${level}`);
}
function probeOllamaHealth() {
if (typeof genericRequest !== 'function') {
return;
}
const baseUrl = $('sa_base_url')?.value || 'http://127.0.0.1:11434';
genericRequest(
'AssistentListModels',
{ baseUrl },
(data) => {
if (data?.error) {
setOllamaHealth('down', 'Ollama ✕', String(data.error));
return;
}
const chat = (data.models || []).length;
const mem = (data.memory_models || []).length;
if (!chat) {
setOllamaHealth('warn', 'Ollama · 0 моделей', 'Нет чат-моделей — сделай ollama pull');
return;
}
setOllamaHealth('ok', `Ollama · ${chat}`, `Чат-моделей: ${chat}, память: ${mem} · ${baseUrl}`);
},
0,
(err) => setOllamaHealth('down', 'Ollama ✕', `Нет связи: ${String(err || '')} · ${baseUrl}`),
);
}
function setCardStatus(msg) {
const el = $('sa_card_status');
if (el) {
@@ -4227,6 +4663,9 @@
saveSettings();
if (state.view === 'cards') {
renderCardsList();
} else if (state.llmParked && !state.generating) {
// Back in the chat — bring the model home before the user hits Send.
warmLlm();
}
}
@@ -4366,6 +4805,10 @@
if (row.has_sidecar) {
metaBits.push('sidecar');
}
if (isWantedModel(row)) {
metaBits.push('⏳ wanted');
btn.classList.add('sa-card-row-wanted');
}
if (row.trigger) {
metaBits.push(String(row.trigger).slice(0, 40));
}
@@ -4863,7 +5306,10 @@
}
const remoteUpdated = remote.updated || 0;
const localUpdated = state.taste?.updated || 0;
if (remoteUpdated >= localUpdated) {
// Disk taste.json is the source of truth; localStorage only wins when it is strictly newer.
const localEmpty = !localUpdated
&& !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
if (localEmpty || remoteUpdated >= localUpdated) {
state.taste = {
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
@@ -5157,7 +5603,7 @@
} else {
why.push('последнего патча Assistent ещё нет');
}
why.push('приоритет: user → session_exact → exact(+persona) → live UI → memory_hits');
why.push('приоритет: user → session_exact → exact(+persona) → live UI → memory_hits (shared+personal)');
const lines = [
'### Debug Assistent',
@@ -5481,6 +5927,8 @@
const chatEpoch = bumpChatEpoch();
state.busy = true;
// Ollama reloads the model for this request (keep_alive 15m), so it is no longer parked.
state.llmParked = false;
setInterruptVisible(true);
startBusyUi('thinking');
saveSettings();
@@ -5565,7 +6013,7 @@
// Refresh cards into context after prefetch
const refreshed = collectLiveContext();
context.model_cards = refreshed.model_cards;
const messages = state.history.slice(-12).map((m) => ({ role: m.role, content: m.content }));
const messages = state.history.slice(-historyMessageLimit()).map((m) => ({ role: m.role, content: m.content }));
if (images && messages.length) {
messages[messages.length - 1].images = images;
}
@@ -5584,16 +6032,6 @@
context_json: JSON.stringify(context),
skills: state.enabledSkills || [],
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
raw: {
messages,
context_json: JSON.stringify(context),
pack,
persona,
base_url: baseUrl,
model,
skills: state.enabledSkills || [],
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
},
};
const finishOk = async (reply, civitaiResults) => {
@@ -5870,6 +6308,29 @@
);
}
/** Disk state first (chats + UI prefs), then config / models / inventory. */
async function bootstrapPersisted() {
try {
await applyDiskUiState();
} catch (e) {
console.warn('Assistent: ui-state restore failed', e);
}
try {
await initChatSessions();
} catch (e) {
console.warn('Assistent: chat sessions failed', e);
}
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
refreshModels();
refreshInventory(() => {
renderCardsList();
renderLoraChips();
});
});
probeOllamaHealth();
refreshWantedQueue();
}
function wire() {
if (!$('swarm_assistent_root')) {
return;
@@ -5893,14 +6354,7 @@
if (wantsAutoVision()) {
refreshImagePreview();
}
initChatSessions();
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
refreshModels();
refreshInventory(() => {
renderCardsList();
renderLoraChips();
});
});
bootstrapPersisted();
wireDropZone();
wireSplitter();
registerSendButton();
@@ -5957,11 +6411,51 @@
const s = $('sa_settings');
if (s) {
s.hidden = !s.hidden;
if (!s.hidden) {
refreshMemoryList();
refreshWantedQueue();
}
}
});
$('sa_btn_mem_refresh')?.addEventListener('click', () => {
refreshMemoryList();
refreshWantedQueue();
});
$('sa_mem_kind')?.addEventListener('change', renderMemoryList);
$('sa_btn_look_result')?.addEventListener('click', () => askLookAtResult());
$('sa_ollama_health')?.addEventListener('click', () => probeOllamaHealth());
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') {
return;
}
let closed = false;
const settings = $('sa_settings');
if (settings && !settings.hidden) {
settings.hidden = true;
closed = true;
}
if (state.chatsPanelOpen) {
setChatsPanelOpen(false);
closed = true;
}
const slash = $('sa_slash_menu');
if (slash && !slash.hidden) {
slash.hidden = true;
closed = true;
}
closeAllMoreMenus();
if (closed) {
e.preventDefault();
}
});
// Focus composer when Assistent tab becomes visible
document.getElementById(TAB_BUTTON_ID)?.addEventListener('click', () => {
setTimeout(() => $('sa_input')?.focus(), 80);
});
$('sa_btn_refresh_models')?.addEventListener('click', () => {
saveSettings();
refreshModels();
probeOllamaHealth();
});
$('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
renderCardsList();
@@ -6104,9 +6598,24 @@
setInterval(updateGate, 2000);
setInterval(syncGenerateSlot, 700);
setInterval(() => {
if (!state.busy && !state.generating) {
probeOllamaHealth();
}
}, 45000);
setInterval(() => {
if (!state.busy && !state.generating) {
refreshWantedQueue();
}
}, 120000);
window.addEventListener('beforeunload', () => {
try {
saveActiveChatToStore({ dropEmpty: true });
const chat = findChat(state.activeChatId);
if (chat && (chat.messages || []).length) {
diskPersist()?.saveChat(chat, { immediate: true });
}
diskPersist()?.saveUiState(collectUiState(), { immediate: true });
} catch (e) { /* ignore */ }
});
setInterval(() => {
+79
View File
@@ -0,0 +1,79 @@
/**
* Swarm Assistent — patch detection / extraction / alias normalization.
* Loaded before assistent.js; mirrors AssistentPatch.cs on the server side.
*/
window.SA = window.SA || {};
(function () {
const PATCH_KEYS = [
'prompt', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler',
'actions', 'search_query', 'civitai_query',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack',
];
const FENCE_RE = /```(?:json)?\s*([\s\S]*?)```/gi;
function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null;
}
/** True when the object looks like a generation patch rather than arbitrary JSON. */
function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
return PATCH_KEYS.some((k) => has(obj, k));
}
/** Maps alias fields onto canonical names, keeping the aliases in place. */
function normalizePatch(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
patch.search_query = patch.civitai_query;
}
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
patch.init_creativity = patch.denoise;
}
if (!has(patch, 'look_at')) {
if (has(patch, 'vision_from')) {
patch.look_at = patch.vision_from;
} else if (has(patch, 'vision_slots')) {
patch.look_at = patch.vision_slots;
}
}
return patch;
}
/** Splits a reply into prose and the last fenced patch object found in it. */
function extractPatch(text) {
if (!text) {
return { prose: text || '', patch: null };
}
const re = new RegExp(FENCE_RE.source, 'gi');
let match;
let lastPatch = null;
let prose = text;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) {
lastPatch = normalizePatch(obj);
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
}
} catch (e) { /* not json */ }
}
return { prose, patch: lastPatch };
}
SA.PATCH_KEYS = PATCH_KEYS;
SA.isPatchObject = isPatchObject;
SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch;
})();
+191
View File
@@ -0,0 +1,191 @@
/**
* Swarm Assistent — disk persistence for chats + UI state (Assistent/chats/, Assistent/ui-state.json).
* Loaded after assistent.api.js and before assistent.js.
*/
window.SA = window.SA || {};
(function () {
const LS_CHATS = 'swarm_assistent_chats_v1';
const LS_MIGRATED = 'swarm_assistent_chats_on_disk_v1';
const SAVE_DEBOUNCE_MS = 700;
const timers = { chats: new Map(), ui: null };
function request(name, body) {
if (typeof SA.request === 'function') {
return SA.request(name, body);
}
return new Promise((resolve, reject) => {
if (typeof genericRequest !== 'function') {
reject(new Error('genericRequest unavailable'));
return;
}
genericRequest(
name,
body || {},
(data) => (data && data.error ? reject(new Error(String(data.error))) : resolve(data)),
0,
(err) => reject(err instanceof Error ? err : new Error(String(err || 'request failed'))),
);
});
}
function normalizeChat(raw) {
if (!raw || !raw.id) {
return null;
}
return {
id: String(raw.id),
title: String(raw.title || 'Новый чат'),
createdAt: Number(raw.createdAt) || Date.now(),
updatedAt: Number(raw.updatedAt) || Number(raw.createdAt) || Date.now(),
messages: Array.isArray(raw.messages) ? raw.messages : [],
params: raw.params && typeof raw.params === 'object' ? raw.params : null,
};
}
function readLocalChats() {
try {
const parsed = JSON.parse(localStorage.getItem(LS_CHATS) || 'null');
if (Array.isArray(parsed?.chats)) {
return parsed.chats.map(normalizeChat).filter(Boolean);
}
} catch (e) { /* ignore */ }
return [];
}
/** One-shot lift of the browser-only history onto the data volume. */
async function migrateLocalChatsToDisk() {
if (localStorage.getItem(LS_MIGRATED) === '1') {
return [];
}
const local = readLocalChats().filter((c) => (c.messages || []).length > 0);
localStorage.setItem(LS_MIGRATED, '1');
if (!local.length) {
return [];
}
for (const chat of local) {
try {
await saveChat(chat, { immediate: true });
} catch (e) {
console.warn('Assistent: chat migration failed', chat.id, e);
}
}
return local;
}
/** Disk chats, newest first. Falls back to a localStorage migration when the volume is empty. */
async function loadChats() {
let chats = [];
try {
const data = await request('AssistentListChats', { with_messages: true });
chats = (data?.chats || []).map(normalizeChat).filter(Boolean);
} catch (e) {
console.warn('Assistent: disk chats unavailable', e);
return null;
}
if (!chats.length) {
const migrated = await migrateLocalChatsToDisk();
if (migrated.length) {
chats = migrated;
}
} else {
localStorage.setItem(LS_MIGRATED, '1');
}
return chats.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}
async function getChat(id) {
if (!id) {
return null;
}
const data = await request('AssistentGetChat', { id });
return normalizeChat(data?.chat);
}
function saveChat(chat, { immediate = false } = {}) {
const clean = normalizeChat(chat);
if (!clean) {
return Promise.resolve(null);
}
const send = () => {
timers.chats.delete(clean.id);
return request('AssistentSaveChat', {
id: clean.id,
title: clean.title,
messages: clean.messages,
params: clean.params,
createdAt: clean.createdAt,
updatedAt: clean.updatedAt,
});
};
if (immediate) {
const pending = timers.chats.get(clean.id);
if (pending) {
clearTimeout(pending);
}
return send();
}
const pending = timers.chats.get(clean.id);
if (pending) {
clearTimeout(pending);
}
timers.chats.set(clean.id, setTimeout(() => {
send().catch((e) => console.warn('Assistent: chat save failed', clean.id, e));
}, SAVE_DEBOUNCE_MS));
return Promise.resolve(null);
}
function deleteChat(id) {
if (!id) {
return Promise.resolve(null);
}
const pending = timers.chats.get(id);
if (pending) {
clearTimeout(pending);
timers.chats.delete(id);
}
return request('AssistentDeleteChat', { id });
}
async function loadUiState() {
try {
const data = await request('AssistentGetUiState', {});
const ui = data?.ui_state;
return ui && typeof ui === 'object' ? ui : null;
} catch (e) {
return null;
}
}
function saveUiState(uiState, { immediate = false } = {}) {
if (!uiState || typeof uiState !== 'object') {
return Promise.resolve(null);
}
const send = () => {
timers.ui = null;
return request('AssistentSaveUiState', { ui_state: uiState });
};
if (timers.ui) {
clearTimeout(timers.ui);
timers.ui = null;
}
if (immediate) {
return send();
}
timers.ui = setTimeout(() => {
send().catch((e) => console.warn('Assistent: ui-state save failed', e));
}, SAVE_DEBOUNCE_MS);
return Promise.resolve(null);
}
SA.persist = {
LS_CHATS,
loadChats,
getChat,
saveChat,
deleteChat,
loadUiState,
saveUiState,
};
})();