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:
@@ -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
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user