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;
|
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 {
|
.sa-board {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -247,43 +237,6 @@
|
|||||||
opacity: 0.9;
|
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 {
|
.sa-image-empty {
|
||||||
padding: 1.25rem 1rem;
|
padding: 1.25rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -302,32 +255,6 @@
|
|||||||
opacity: 0.9;
|
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 {
|
.sa-image-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -786,6 +713,132 @@
|
|||||||
margin-bottom: 0.25rem;
|
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 {
|
.sa-settings .sa-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
+597
-88
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Swarm Assistent — Krea 2 collaborative chat (Ollama via Swarm API).
|
* 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 () {
|
(function () {
|
||||||
const LS_BASE = 'swarm_assistent_base_url';
|
const LS_BASE = 'swarm_assistent_base_url';
|
||||||
@@ -24,8 +24,9 @@
|
|||||||
const MAX_CHAT_MSGS = 24;
|
const MAX_CHAT_MSGS = 24;
|
||||||
const TAB_BUTTON_ID = 'maintab_assistent';
|
const TAB_BUTTON_ID = 'maintab_assistent';
|
||||||
const GEN_ID = 'generate';
|
const GEN_ID = 'generate';
|
||||||
const MAX_REF_SLOTS = 4;
|
let MAX_REF_SLOTS = 4;
|
||||||
const CONTEXT_PROMPT_MAX = 2000;
|
let CONTEXT_PROMPT_MAX = 2000;
|
||||||
|
let HISTORY_KEEP_TURNS = 4;
|
||||||
|
|
||||||
let ASPECT_TABLE = {
|
let ASPECT_TABLE = {
|
||||||
'1:1': [1024, 1024],
|
'1:1': [1024, 1024],
|
||||||
@@ -155,8 +156,18 @@
|
|||||||
restoringChat: false,
|
restoringChat: false,
|
||||||
chatsPanelOpen: false,
|
chatsPanelOpen: false,
|
||||||
slashIndex: 0,
|
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) {
|
function $(id) {
|
||||||
return document.getElementById(id);
|
return document.getElementById(id);
|
||||||
}
|
}
|
||||||
@@ -215,6 +226,7 @@
|
|||||||
thinking: 'Thinking…',
|
thinking: 'Thinking…',
|
||||||
streaming: 'Writing…',
|
streaming: 'Writing…',
|
||||||
generating: 'Generating image…',
|
generating: 'Generating image…',
|
||||||
|
parking: 'Освобождаю VRAM (park LLM)…',
|
||||||
applying: 'Applying patch…',
|
applying: 'Applying patch…',
|
||||||
silent_gen: 'Применяю патч → Generate…',
|
silent_gen: 'Применяю патч → Generate…',
|
||||||
refining: 'Civitai search done — refining…',
|
refining: 'Civitai search done — refining…',
|
||||||
@@ -676,6 +688,7 @@
|
|||||||
const tab = document.getElementById(TAB_BUTTON_ID);
|
const tab = document.getElementById(TAB_BUTTON_ID);
|
||||||
if (tab) {
|
if (tab) {
|
||||||
tab.click();
|
tab.click();
|
||||||
|
setTimeout(() => $('sa_input')?.focus(), 50);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const pane = document.getElementById('assistent');
|
const pane = document.getElementById('assistent');
|
||||||
@@ -684,9 +697,15 @@
|
|||||||
bootstrap.Tab.getOrCreateInstance(tab || pane).show();
|
bootstrap.Tab.getOrCreateInstance(tab || pane).show();
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
setTimeout(() => $('sa_input')?.focus(), 50);
|
||||||
return !!tab;
|
return !!tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function historyMessageLimit() {
|
||||||
|
const turns = Math.max(1, Number(HISTORY_KEEP_TURNS) || 4);
|
||||||
|
return turns * 2;
|
||||||
|
}
|
||||||
|
|
||||||
function flashImagePane(slotId) {
|
function flashImagePane(slotId) {
|
||||||
const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
|
const el = document.querySelector(`.sa-slot[data-id="${slotId || state.selectedSlotId}"]`);
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -1540,6 +1559,7 @@
|
|||||||
}));
|
}));
|
||||||
state.chats = chats;
|
state.chats = chats;
|
||||||
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
|
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats }));
|
||||||
|
saveActiveChatToDisk();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Assistent: persist chats failed', e);
|
console.warn('Assistent: persist chats failed', e);
|
||||||
try {
|
try {
|
||||||
@@ -1550,19 +1570,33 @@
|
|||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
messages: slimHistoryMessages(c.messages).slice(-12).map((m) => ({
|
messages: slimHistoryMessages(c.messages).slice(-historyMessageLimit()).map((m) => ({
|
||||||
...m,
|
...m,
|
||||||
content: String(m.content || '').slice(0, 1500),
|
content: String(m.content || '').slice(0, 1500),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
state.chats = slim;
|
state.chats = slim;
|
||||||
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
|
localStorage.setItem(LS_CHATS, JSON.stringify({ version: 1, chats: slim }));
|
||||||
|
saveActiveChatToDisk();
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
console.warn('Assistent: chats quota fallback failed', 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() {
|
function loadChatsStore() {
|
||||||
state.chats = [];
|
state.chats = [];
|
||||||
try {
|
try {
|
||||||
@@ -1577,6 +1611,28 @@
|
|||||||
migrateLegacyHistoryIntoChats();
|
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() {
|
function migrateLegacyHistoryIntoChats() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LS_HISTORY);
|
const raw = localStorage.getItem(LS_HISTORY);
|
||||||
@@ -1862,6 +1918,7 @@
|
|||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
state.activeChatId = null;
|
state.activeChatId = null;
|
||||||
}
|
}
|
||||||
|
diskPersist()?.deleteChat(id)?.catch?.((e) => console.warn('Assistent: disk delete failed', e));
|
||||||
persistChatsStore();
|
persistChatsStore();
|
||||||
syncHistoryBadge();
|
syncHistoryBadge();
|
||||||
if (wasActive) {
|
if (wasActive) {
|
||||||
@@ -1871,11 +1928,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initChatSessions() {
|
async function initChatSessions() {
|
||||||
loadChatsStore();
|
loadChatsStore();
|
||||||
|
await loadChatsFromDisk();
|
||||||
// Always open a fresh chat on startup; past chats stay in History.
|
// Always open a fresh chat on startup; past chats stay in History.
|
||||||
startNewChat({ saveCurrent: false, force: true });
|
startNewChat({ saveCurrent: false, force: true });
|
||||||
syncHistoryBadge();
|
syncHistoryBadge();
|
||||||
|
renderChatsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistHistory() {
|
function persistHistory() {
|
||||||
@@ -2588,51 +2647,21 @@
|
|||||||
saveTaste();
|
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) {
|
function isPatchObject(obj) {
|
||||||
|
if (window.SA && typeof SA.isPatchObject === 'function') {
|
||||||
|
return SA.isPatchObject(obj);
|
||||||
|
}
|
||||||
if (!obj || typeof obj !== 'object') {
|
if (!obj || typeof obj !== 'object') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return (
|
return FALLBACK_PATCH_KEYS.some((k) => obj[k] !== undefined && obj[k] !== null);
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCardObject(obj) {
|
function isCardObject(obj) {
|
||||||
@@ -2676,6 +2705,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractPatch(text) {
|
function extractPatch(text) {
|
||||||
|
if (window.SA && typeof SA.extractPatch === 'function') {
|
||||||
|
return SA.extractPatch(text);
|
||||||
|
}
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return { prose: text || '', patch: null };
|
return { prose: text || '', patch: null };
|
||||||
}
|
}
|
||||||
@@ -2684,9 +2716,8 @@
|
|||||||
let lastPatch = null;
|
let lastPatch = null;
|
||||||
let prose = text;
|
let prose = text;
|
||||||
while ((match = re.exec(text)) !== null) {
|
while ((match = re.exec(text)) !== null) {
|
||||||
const raw = match[1].trim();
|
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(raw);
|
const obj = JSON.parse(match[1].trim());
|
||||||
if (isPatchObject(obj)) {
|
if (isPatchObject(obj)) {
|
||||||
lastPatch = obj;
|
lastPatch = obj;
|
||||||
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
|
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
|
||||||
@@ -3169,6 +3200,42 @@
|
|||||||
return false;
|
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() {
|
function cancelWaitForNewImage() {
|
||||||
if (state.waitImageTimer) {
|
if (state.waitImageTimer) {
|
||||||
clearInterval(state.waitImageTimer);
|
clearInterval(state.waitImageTimer);
|
||||||
@@ -3259,6 +3326,9 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const prev = findCurrentGenerateSrc();
|
const prev = findCurrentGenerateSrc();
|
||||||
|
startBusyUi('parking');
|
||||||
|
setStatus('Освобождаю VRAM…');
|
||||||
|
await parkLlm();
|
||||||
setStatus('Генерация…');
|
setStatus('Генерация…');
|
||||||
startBusyUi('generating');
|
startBusyUi('generating');
|
||||||
state.generating = true;
|
state.generating = true;
|
||||||
@@ -3276,6 +3346,9 @@
|
|||||||
if (!state.busy) {
|
if (!state.busy) {
|
||||||
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
|
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
|
||||||
}
|
}
|
||||||
|
if (state.view === 'chat') {
|
||||||
|
warmLlm();
|
||||||
|
}
|
||||||
if (src) {
|
if (src) {
|
||||||
const gen = generateSlot();
|
const gen = generateSlot();
|
||||||
if (gen) {
|
if (gen) {
|
||||||
@@ -3291,31 +3364,77 @@
|
|||||||
return null;
|
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) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
state.critiqueHopUsed = true;
|
state.critiqueHopUsed = true;
|
||||||
const pack = $('sa_pack');
|
setPackValue('critique_image', { flash: true });
|
||||||
if (pack) {
|
|
||||||
pack.value = 'critique_image';
|
|
||||||
saveSettings();
|
|
||||||
}
|
|
||||||
if ($('sa_input')) {
|
if ($('sa_input')) {
|
||||||
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
|
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
|
||||||
}
|
}
|
||||||
const gen = generateSlot();
|
const gen = generateSlot();
|
||||||
if (gen) {
|
if (gen) {
|
||||||
gen.attach = true;
|
gen.attach = true;
|
||||||
if (imageSrc) {
|
gen.src = src;
|
||||||
gen.src = imageSrc;
|
|
||||||
}
|
|
||||||
renderBoard();
|
renderBoard();
|
||||||
}
|
}
|
||||||
setStatus('Auto-critique…');
|
setStatus('Auto-critique…');
|
||||||
await sendChat({ fromAutoCritique: true, forceSlotIds: [GEN_ID] });
|
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() {
|
function currentPersonaInfo() {
|
||||||
const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
|
const id = ($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral').trim() || 'neutral';
|
||||||
const known = (state.personas || []).find((p) => p && p.id === id);
|
const known = (state.personas || []).find((p) => p && p.id === id);
|
||||||
@@ -3505,18 +3624,18 @@
|
|||||||
actions.className = 'sa-civitai-actions';
|
actions.className = 'sa-civitai-actions';
|
||||||
if (r.already_installed) {
|
if (r.already_installed) {
|
||||||
const note = document.createElement('span');
|
const note = document.createElement('span');
|
||||||
note.textContent = 'Installed';
|
note.textContent = 'Уже установлена';
|
||||||
actions.appendChild(note);
|
actions.appendChild(note);
|
||||||
} else if (r.download_url) {
|
} else if (r.download_url) {
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'basic-button sa-primary';
|
btn.className = 'basic-button sa-primary';
|
||||||
btn.textContent = 'Confirm download';
|
btn.textContent = 'Подтвердить скачивание';
|
||||||
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
|
btn.addEventListener('click', () => downloadCivitaiLoRA(r, btn));
|
||||||
actions.appendChild(btn);
|
actions.appendChild(btn);
|
||||||
} else {
|
} else {
|
||||||
const note = document.createElement('span');
|
const note = document.createElement('span');
|
||||||
note.textContent = 'No download URL';
|
note.textContent = 'Нет URL скачивания';
|
||||||
actions.appendChild(note);
|
actions.appendChild(note);
|
||||||
}
|
}
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
@@ -3531,9 +3650,9 @@
|
|||||||
}
|
}
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Downloading…';
|
btn.textContent = 'Скачиваю…';
|
||||||
}
|
}
|
||||||
setStatus(`Downloading ${card.file_name || card.name}…`);
|
setStatus(`Скачиваю ${card.file_name || card.name}…`);
|
||||||
setInterruptVisible(true);
|
setInterruptVisible(true);
|
||||||
const payload = {
|
const payload = {
|
||||||
url: card.download_url,
|
url: card.download_url,
|
||||||
@@ -3543,9 +3662,9 @@
|
|||||||
const onDone = (ok, msg) => {
|
const onDone = (ok, msg) => {
|
||||||
setInterruptVisible(state.busy || state.generating);
|
setInterruptVisible(state.busy || state.generating);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
setStatus(`Downloaded ${payload.name}`);
|
setStatus(`Скачано ${payload.name}`);
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.textContent = 'Downloaded';
|
btn.textContent = 'Скачано';
|
||||||
}
|
}
|
||||||
refreshInventory(async () => {
|
refreshInventory(async () => {
|
||||||
await maybeWriteCardAfterDownload({
|
await maybeWriteCardAfterDownload({
|
||||||
@@ -3555,12 +3674,12 @@
|
|||||||
});
|
});
|
||||||
}, { rescan: true });
|
}, { rescan: true });
|
||||||
} else {
|
} else {
|
||||||
setStatus(msg || 'Download failed');
|
setStatus(msg || 'Ошибка скачивания');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Confirm download';
|
btn.textContent = 'Подтвердить скачивание';
|
||||||
}
|
}
|
||||||
appendMessage('error', msg || 'Download failed');
|
appendMessage('error', msg || 'Ошибка скачивания');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (typeof makeWSRequest === 'function') {
|
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() {
|
function saveSettings() {
|
||||||
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
|
localStorage.setItem(LS_BASE, $('sa_base_url')?.value || '');
|
||||||
localStorage.setItem(LS_MODEL, $('sa_model')?.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_CRITIQUE, $('sa_auto_critique')?.checked ? '1' : '0');
|
||||||
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
|
localStorage.setItem(LS_AUTO_DOWNLOAD, $('sa_auto_download')?.checked ? '1' : '0');
|
||||||
persistServerSettings();
|
persistServerSettings();
|
||||||
|
saveUiStateToDisk();
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistServerSettings() {
|
function persistServerSettings() {
|
||||||
@@ -3936,6 +4138,16 @@
|
|||||||
if (data.assistant?.embed_model && !state.preferredEmbed) {
|
if (data.assistant?.embed_model && !state.preferredEmbed) {
|
||||||
state.preferredEmbed = data.assistant.embed_model;
|
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) {
|
if (applyDefaults || data.exact) {
|
||||||
fillEmptyParamsFromExact();
|
fillEmptyParamsFromExact();
|
||||||
}
|
}
|
||||||
@@ -4147,6 +4359,11 @@
|
|||||||
$('sa_model').value = prefer;
|
$('sa_model').value = prefer;
|
||||||
}
|
}
|
||||||
setStatus(models.length ? `${models.length} chat · ${memoryModels.length} memory` : 'No Ollama models (gpu-rent: ollama pull)');
|
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();
|
saveSettings();
|
||||||
},
|
},
|
||||||
0,
|
0,
|
||||||
@@ -4154,6 +4371,7 @@
|
|||||||
const msg = String(err || 'Ollama unreachable');
|
const msg = String(err || 'Ollama unreachable');
|
||||||
setStatus(msg);
|
setStatus(msg);
|
||||||
setModelOptions([], { error: msg });
|
setModelOptions([], { error: msg });
|
||||||
|
setOllamaHealth('down', 'Ollama ✕', msg);
|
||||||
appendMessage('error', msg);
|
appendMessage('error', msg);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4205,6 +4423,224 @@
|
|||||||
return new Promise((resolve) => refreshInventory(resolve, opts));
|
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) {
|
function setCardStatus(msg) {
|
||||||
const el = $('sa_card_status');
|
const el = $('sa_card_status');
|
||||||
if (el) {
|
if (el) {
|
||||||
@@ -4227,6 +4663,9 @@
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
if (state.view === 'cards') {
|
if (state.view === 'cards') {
|
||||||
renderCardsList();
|
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) {
|
if (row.has_sidecar) {
|
||||||
metaBits.push('sidecar');
|
metaBits.push('sidecar');
|
||||||
}
|
}
|
||||||
|
if (isWantedModel(row)) {
|
||||||
|
metaBits.push('⏳ wanted');
|
||||||
|
btn.classList.add('sa-card-row-wanted');
|
||||||
|
}
|
||||||
if (row.trigger) {
|
if (row.trigger) {
|
||||||
metaBits.push(String(row.trigger).slice(0, 40));
|
metaBits.push(String(row.trigger).slice(0, 40));
|
||||||
}
|
}
|
||||||
@@ -4863,7 +5306,10 @@
|
|||||||
}
|
}
|
||||||
const remoteUpdated = remote.updated || 0;
|
const remoteUpdated = remote.updated || 0;
|
||||||
const localUpdated = state.taste?.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 = {
|
state.taste = {
|
||||||
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
|
styles: Array.isArray(remote.styles) ? remote.styles.slice(0, 12) : [],
|
||||||
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
|
likes: Array.isArray(remote.likes) ? remote.likes.slice(0, 16) : [],
|
||||||
@@ -5157,7 +5603,7 @@
|
|||||||
} else {
|
} else {
|
||||||
why.push('последнего патча Assistent ещё нет');
|
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 = [
|
const lines = [
|
||||||
'### Debug Assistent',
|
'### Debug Assistent',
|
||||||
@@ -5481,6 +5927,8 @@
|
|||||||
|
|
||||||
const chatEpoch = bumpChatEpoch();
|
const chatEpoch = bumpChatEpoch();
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
|
// Ollama reloads the model for this request (keep_alive 15m), so it is no longer parked.
|
||||||
|
state.llmParked = false;
|
||||||
setInterruptVisible(true);
|
setInterruptVisible(true);
|
||||||
startBusyUi('thinking');
|
startBusyUi('thinking');
|
||||||
saveSettings();
|
saveSettings();
|
||||||
@@ -5565,7 +6013,7 @@
|
|||||||
// Refresh cards into context after prefetch
|
// Refresh cards into context after prefetch
|
||||||
const refreshed = collectLiveContext();
|
const refreshed = collectLiveContext();
|
||||||
context.model_cards = refreshed.model_cards;
|
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) {
|
if (images && messages.length) {
|
||||||
messages[messages.length - 1].images = images;
|
messages[messages.length - 1].images = images;
|
||||||
}
|
}
|
||||||
@@ -5584,16 +6032,6 @@
|
|||||||
context_json: JSON.stringify(context),
|
context_json: JSON.stringify(context),
|
||||||
skills: state.enabledSkills || [],
|
skills: state.enabledSkills || [],
|
||||||
embed_model: $('sa_embed_model')?.value || state.preferredEmbed || '',
|
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) => {
|
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() {
|
function wire() {
|
||||||
if (!$('swarm_assistent_root')) {
|
if (!$('swarm_assistent_root')) {
|
||||||
return;
|
return;
|
||||||
@@ -5893,14 +6354,7 @@
|
|||||||
if (wantsAutoVision()) {
|
if (wantsAutoVision()) {
|
||||||
refreshImagePreview();
|
refreshImagePreview();
|
||||||
}
|
}
|
||||||
initChatSessions();
|
bootstrapPersisted();
|
||||||
loadConfig(localStorage.getItem(LS_PERSONA) || 'neutral', () => {
|
|
||||||
refreshModels();
|
|
||||||
refreshInventory(() => {
|
|
||||||
renderCardsList();
|
|
||||||
renderLoraChips();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
wireDropZone();
|
wireDropZone();
|
||||||
wireSplitter();
|
wireSplitter();
|
||||||
registerSendButton();
|
registerSendButton();
|
||||||
@@ -5957,11 +6411,51 @@
|
|||||||
const s = $('sa_settings');
|
const s = $('sa_settings');
|
||||||
if (s) {
|
if (s) {
|
||||||
s.hidden = !s.hidden;
|
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', () => {
|
$('sa_btn_refresh_models')?.addEventListener('click', () => {
|
||||||
saveSettings();
|
saveSettings();
|
||||||
refreshModels();
|
refreshModels();
|
||||||
|
probeOllamaHealth();
|
||||||
});
|
});
|
||||||
$('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
|
$('sa_btn_refresh_inventory')?.addEventListener('click', () => refreshInventory(() => {
|
||||||
renderCardsList();
|
renderCardsList();
|
||||||
@@ -6104,9 +6598,24 @@
|
|||||||
|
|
||||||
setInterval(updateGate, 2000);
|
setInterval(updateGate, 2000);
|
||||||
setInterval(syncGenerateSlot, 700);
|
setInterval(syncGenerateSlot, 700);
|
||||||
|
setInterval(() => {
|
||||||
|
if (!state.busy && !state.generating) {
|
||||||
|
probeOllamaHealth();
|
||||||
|
}
|
||||||
|
}, 45000);
|
||||||
|
setInterval(() => {
|
||||||
|
if (!state.busy && !state.generating) {
|
||||||
|
refreshWantedQueue();
|
||||||
|
}
|
||||||
|
}, 120000);
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
try {
|
try {
|
||||||
saveActiveChatToStore({ dropEmpty: true });
|
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 */ }
|
} catch (e) { /* ignore */ }
|
||||||
});
|
});
|
||||||
setInterval(() => {
|
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,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Prompt assembly, memory retrieval/writeback and the Civitai search hop loop.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
const int MaxCivitaiHopsFallback = 2;
|
||||||
|
|
||||||
|
List<JObject> BuildOllamaMessages(string packName, bool includeBase, string contextJson, JArray userMessages, string extraSystem = null, string personaId = null, IEnumerable<string> skillIds = null)
|
||||||
|
{
|
||||||
|
List<JObject> ollamaMessages = [];
|
||||||
|
StringBuilder system = new();
|
||||||
|
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
|
||||||
|
|
||||||
|
if (includeBase)
|
||||||
|
{
|
||||||
|
string core = Config.LoadCorePrompt(pid);
|
||||||
|
if (!string.IsNullOrWhiteSpace(core))
|
||||||
|
{
|
||||||
|
system.AppendLine(core);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject exact = Config.LoadExactForPrompt(pid);
|
||||||
|
if (exact is not null && exact.Count > 0)
|
||||||
|
{
|
||||||
|
system.AppendLine();
|
||||||
|
system.AppendLine("## Exact memory (canonical KV defaults — prefer over RAG for numbers)");
|
||||||
|
system.AppendLine("```json");
|
||||||
|
system.AppendLine(exact.ToString(Newtonsoft.Json.Formatting.None));
|
||||||
|
system.AppendLine("```");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string skillId in skillIds ?? Config.ResolveEnabledSkills(pid, null))
|
||||||
|
{
|
||||||
|
string skillText = Config.LoadSkillPrompt(pid, skillId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(skillText))
|
||||||
|
{
|
||||||
|
system.AppendLine();
|
||||||
|
system.AppendLine($"## Skill: {skillId}");
|
||||||
|
system.AppendLine(skillText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string identity = Config.RenderIdentityBlock(pid);
|
||||||
|
if (!string.IsNullOrWhiteSpace(identity))
|
||||||
|
{
|
||||||
|
system.AppendLine();
|
||||||
|
system.AppendLine(identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(packName))
|
||||||
|
{
|
||||||
|
string situational = Config.LoadPackPrompt(pid, packName);
|
||||||
|
if (!string.IsNullOrWhiteSpace(situational))
|
||||||
|
{
|
||||||
|
system.AppendLine();
|
||||||
|
system.AppendLine($"## Active mode: {packName}");
|
||||||
|
system.AppendLine(situational);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(contextJson))
|
||||||
|
{
|
||||||
|
system.AppendLine();
|
||||||
|
system.AppendLine("## Live SwarmUI context (JSON — trust this over guesses)");
|
||||||
|
system.AppendLine("```json");
|
||||||
|
system.AppendLine(contextJson);
|
||||||
|
system.AppendLine("```");
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(extraSystem))
|
||||||
|
{
|
||||||
|
system.AppendLine();
|
||||||
|
system.AppendLine(extraSystem);
|
||||||
|
}
|
||||||
|
if (system.Length > 0)
|
||||||
|
{
|
||||||
|
ollamaMessages.Add(new JObject
|
||||||
|
{
|
||||||
|
["role"] = "system",
|
||||||
|
["content"] = system.ToString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (JToken msg in userMessages ?? [])
|
||||||
|
{
|
||||||
|
if (msg is not JObject mo)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject copy = new()
|
||||||
|
{
|
||||||
|
["role"] = mo["role"]?.ToString() ?? "user",
|
||||||
|
["content"] = mo["content"]?.ToString() ?? "",
|
||||||
|
};
|
||||||
|
if (mo["images"] is JArray images && images.Count > 0)
|
||||||
|
{
|
||||||
|
copy["images"] = images;
|
||||||
|
}
|
||||||
|
ollamaMessages.Add(copy);
|
||||||
|
}
|
||||||
|
return ollamaMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<(string reply, JObject raw, JArray civitaiResults)> RunChatWithHops(
|
||||||
|
Session session,
|
||||||
|
string root,
|
||||||
|
string modelName,
|
||||||
|
string packName,
|
||||||
|
bool includeBase,
|
||||||
|
string contextJson,
|
||||||
|
JArray userMessages,
|
||||||
|
Func<string, Task> onDelta = null,
|
||||||
|
Func<int, Task> onHopStart = null,
|
||||||
|
string personaId = null,
|
||||||
|
JArray skillIds = null,
|
||||||
|
string embedModel = null)
|
||||||
|
{
|
||||||
|
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
|
||||||
|
List<string> skills = Config.ResolveEnabledSkills(pid, skillIds);
|
||||||
|
string embed = string.IsNullOrWhiteSpace(embedModel)
|
||||||
|
? (Config.LoadSettings()["embed_model"]?.ToString()
|
||||||
|
?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
|
||||||
|
?? "nomic-embed-text")
|
||||||
|
: embedModel;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Memory.EnsureSeedAsync(root, Config, embed);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"Assistent memory seed: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
string retrieveQuery = BuildRetrieveQuery(userMessages, contextJson);
|
||||||
|
JArray hits = [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int topK = Config.LoadAssistant(pid)["memory_top_k"]?.Value<int?>() ?? 10;
|
||||||
|
hits = await Memory.RetrieveAsync(root, retrieveQuery, topK, embed, Config.PersonaExtendsChain(pid));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"Assistent memory retrieve: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
string enrichedContext = InjectMemoryHits(contextJson, hits);
|
||||||
|
List<JObject> messages = BuildOllamaMessages(packName, includeBase, enrichedContext, userMessages, personaId: pid, skillIds: skills);
|
||||||
|
JArray civitaiResults = [];
|
||||||
|
string reply = "";
|
||||||
|
JObject lastRaw = null;
|
||||||
|
int maxHops = CfgInt("max_civitai_hops", MaxCivitaiHopsFallback);
|
||||||
|
for (int hop = 0; hop < maxHops; hop++)
|
||||||
|
{
|
||||||
|
if (onHopStart is not null)
|
||||||
|
{
|
||||||
|
await onHopStart(hop);
|
||||||
|
}
|
||||||
|
(reply, lastRaw) = await CallOllamaChat(root, modelName, messages, stream: onDelta is not null, onDelta, pid);
|
||||||
|
JObject patch = TryParsePatch(reply);
|
||||||
|
await ApplyMemoryActions(root, patch, embed, pid);
|
||||||
|
if (hop + 1 >= maxHops || !WantsCivitaiSearch(patch))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
string query = ExtractSearchQuery(patch);
|
||||||
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
JObject search = await AssistentSearchCivitai(session, query, 8);
|
||||||
|
if (search["error"] is not null)
|
||||||
|
{
|
||||||
|
messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
|
||||||
|
messages.Add(new JObject
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] = $"Civitai search failed: {search["error"]}. Continue without download — use only available_loras from context.",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
civitaiResults = search["results"] as JArray ?? [];
|
||||||
|
messages.Add(new JObject { ["role"] = "assistant", ["content"] = reply });
|
||||||
|
messages.Add(new JObject
|
||||||
|
{
|
||||||
|
["role"] = "user",
|
||||||
|
["content"] =
|
||||||
|
"Civitai search results (JSON). Prefer `krea_likely: true`. Do NOT download yourself — the UI shows Confirm cards. " +
|
||||||
|
"Pick useful LoRAs from results or available_loras, emit a normal patch (prompt/loras). " +
|
||||||
|
"Omit search_civitai from actions unless you need a different query.\n```json\n" +
|
||||||
|
civitaiResults.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (reply, lastRaw, civitaiResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string BuildRetrieveQuery(JArray userMessages, string contextJson)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new();
|
||||||
|
if (!string.IsNullOrWhiteSpace(contextJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject ctx = JObject.Parse(contextJson);
|
||||||
|
string ckpt = ctx["checkpoint"]?.ToString() ?? ctx["current_model"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(ckpt))
|
||||||
|
{
|
||||||
|
sb.Append(ckpt).Append(' ');
|
||||||
|
}
|
||||||
|
if (ctx["enabled_loras"] is JArray en)
|
||||||
|
{
|
||||||
|
foreach (JToken t in en.Take(8))
|
||||||
|
{
|
||||||
|
string n = t?["name"]?.ToString() ?? t?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(n))
|
||||||
|
{
|
||||||
|
sb.Append(n).Append(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ctx["krea_profile"] != null)
|
||||||
|
{
|
||||||
|
sb.Append("krea ").Append(ctx["krea_profile"]).Append(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (JToken msg in (userMessages ?? []).Reverse().Take(2))
|
||||||
|
{
|
||||||
|
if (msg is JObject mo && string.Equals(mo["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
sb.Append(mo["content"]?.ToString()).Append(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
string q = CollapseWs(sb.ToString());
|
||||||
|
return string.IsNullOrWhiteSpace(q) ? "krea2 prompting" : q;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
|
||||||
|
{
|
||||||
|
JObject ctx;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ctx = string.IsNullOrWhiteSpace(contextJson) ? new JObject() : JObject.Parse(contextJson);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
ctx = new JObject { ["_raw_context"] = contextJson };
|
||||||
|
}
|
||||||
|
ctx["memory_hits"] = hits ?? new JArray();
|
||||||
|
// Never re-inject full Exact into live context (already in system prompt).
|
||||||
|
ctx.Remove("exact");
|
||||||
|
if (ctx["session_exact"] is null)
|
||||||
|
{
|
||||||
|
ctx["session_exact"] = new JObject();
|
||||||
|
}
|
||||||
|
// Slim inventory for LLM: keep enabled + current, drop full dump if present
|
||||||
|
if (ctx["available_loras"] is JArray allLoras && allLoras.Count > 24)
|
||||||
|
{
|
||||||
|
HashSet<string> keep = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (ctx["enabled_loras"] is JArray en)
|
||||||
|
{
|
||||||
|
foreach (JToken t in en)
|
||||||
|
{
|
||||||
|
string n = t?["name"]?.ToString() ?? t?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(n))
|
||||||
|
{
|
||||||
|
keep.Add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (JToken hit in hits ?? [])
|
||||||
|
{
|
||||||
|
if (string.Equals(hit?["kind"]?.ToString(), "lora", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(hit?["kind"]?.ToString(), "card", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
string k = hit?["key"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(k))
|
||||||
|
{
|
||||||
|
keep.Add(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JArray slim = [];
|
||||||
|
foreach (JToken t in allLoras)
|
||||||
|
{
|
||||||
|
string n = t?["name"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(n) && (keep.Contains(n) || slim.Count < 12))
|
||||||
|
{
|
||||||
|
if (keep.Contains(n) || t?["krea_likely"]?.Value<bool>() == true)
|
||||||
|
{
|
||||||
|
slim.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slim.Count == 0)
|
||||||
|
{
|
||||||
|
foreach (JToken t in allLoras.Take(12))
|
||||||
|
{
|
||||||
|
slim.Add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx["available_loras"] = slim;
|
||||||
|
ctx["available_loras_truncated"] = true;
|
||||||
|
ctx["available_loras_total"] = allLoras.Count;
|
||||||
|
}
|
||||||
|
return ctx.ToString(Newtonsoft.Json.Formatting.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
static string MemoryWritePersona(JObject mo, string currentPersonaId)
|
||||||
|
{
|
||||||
|
string scope = (mo?["scope"]?.ToString() ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (scope is "shared" or "common" or "global")
|
||||||
|
{
|
||||||
|
return AssistentMemory.SharedPersona;
|
||||||
|
}
|
||||||
|
// Personal only — never let the model write into another personality's store.
|
||||||
|
return AssistentConfig.SafeId(currentPersonaId) ?? AssistentMemory.SharedPersona;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task ApplyMemoryActions(string root, JObject patch, string embedModel, 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, "memory_upsert", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
upsert = true;
|
||||||
|
}
|
||||||
|
if (string.Equals(s, "memory_forget", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
forget = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JArray memories = patch["memories"] as JArray;
|
||||||
|
if (memories is null || memories.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (JToken t in memories)
|
||||||
|
{
|
||||||
|
if (t is not JObject mo)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string kind = mo["kind"]?.ToString() ?? "note";
|
||||||
|
string key = mo["key"]?.ToString() ?? "";
|
||||||
|
string text = mo["text"]?.ToString() ?? "";
|
||||||
|
string target = MemoryWritePersona(mo, personaId);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (forget && string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
Memory.Forget(kind, key, persona: target);
|
||||||
|
}
|
||||||
|
else if (upsert || !string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
await Memory.UpsertTextAsync(root, kind, key, text, "user", mo, embedModel, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"ApplyMemoryActions: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
-33
@@ -189,7 +189,8 @@ public sealed class AssistentConfig
|
|||||||
return last;
|
return last;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<string> PersonaExtendsChain(string personaId)
|
/// <summary>Ancestor-first chain ending with the current persona (for overlay merge and vector retrieve).</summary>
|
||||||
|
public List<string> PersonaExtendsChain(string personaId)
|
||||||
{
|
{
|
||||||
List<string> chain = [];
|
List<string> chain = [];
|
||||||
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||||
@@ -498,27 +499,35 @@ public sealed class AssistentConfig
|
|||||||
JObject rules = MergeJsonLayers("rules.json", roots);
|
JObject rules = MergeJsonLayers("rules.json", roots);
|
||||||
string extra = MergeTextLayers("extra.md", roots);
|
string extra = MergeTextLayers("extra.md", roots);
|
||||||
|
|
||||||
// Legacy personas.json prompt → extra overlay
|
// Legacy personas.json: only when no overlay persona folder exists for this id
|
||||||
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
// (gpu-rent now seeds personas/<id>/extra.md instead of dumping personas.json).
|
||||||
JObject legacy = TryReadJson(overlayJson);
|
string id = SafeId(personaId) ?? "neutral";
|
||||||
if (legacy?["personas"] is JArray arr)
|
string overlayPersonaDir = Path.Combine(_overlayRoot, "personas", id);
|
||||||
|
bool hasOverlayFolder = Directory.Exists(overlayPersonaDir)
|
||||||
|
&& (File.Exists(Path.Combine(overlayPersonaDir, "persona.json"))
|
||||||
|
|| File.Exists(Path.Combine(overlayPersonaDir, "extra.md")));
|
||||||
|
if (!hasOverlayFolder)
|
||||||
{
|
{
|
||||||
string id = SafeId(personaId) ?? "neutral";
|
string overlayJson = Path.Combine(_overlayRoot, "personas.json");
|
||||||
foreach (JToken t in arr)
|
JObject legacy = TryReadJson(overlayJson);
|
||||||
|
if (legacy?["personas"] is JArray arr)
|
||||||
{
|
{
|
||||||
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
|
foreach (JToken t in arr)
|
||||||
{
|
{
|
||||||
string title = po["title"]?.ToString();
|
if (t is JObject po && string.Equals(SafeId(po["id"]?.ToString()), id, StringComparison.OrdinalIgnoreCase))
|
||||||
if (!string.IsNullOrWhiteSpace(title))
|
|
||||||
{
|
{
|
||||||
persona["title"] = title;
|
string title = po["title"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
persona["title"] = title;
|
||||||
|
}
|
||||||
|
string prompt = po["prompt"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(prompt))
|
||||||
|
{
|
||||||
|
extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
string prompt = po["prompt"]?.ToString();
|
|
||||||
if (!string.IsNullOrWhiteSpace(prompt))
|
|
||||||
{
|
|
||||||
extra = string.IsNullOrWhiteSpace(extra) ? prompt : extra + "\n\n" + prompt;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -641,35 +650,51 @@ public sealed class AssistentConfig
|
|||||||
return sb.ToString().TrimEnd();
|
return sb.ToString().TrimEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static IEnumerable<string> PersonaIdsUnder(string root)
|
||||||
|
{
|
||||||
|
string dir = Path.Combine(root ?? "", "personas");
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
foreach (string folder in Directory.GetDirectories(dir))
|
||||||
|
{
|
||||||
|
string id = SafeId(Path.GetFileName(folder));
|
||||||
|
if (id is not null)
|
||||||
|
{
|
||||||
|
yield return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Seed docs: shared from _base/memory-seed, personal from personas/<id>/memory-seed. Later files overwrite earlier same kind+key in the list; persona is stamped on each doc.</summary>
|
||||||
public List<JObject> LoadMemorySeedDocs()
|
public List<JObject> LoadMemorySeedDocs()
|
||||||
{
|
{
|
||||||
List<JObject> docs = [];
|
List<JObject> docs = [];
|
||||||
void Scan(string root)
|
void Scan(string dir, string persona)
|
||||||
{
|
{
|
||||||
string dir = Path.Combine(root, "memory-seed");
|
if (string.IsNullOrWhiteSpace(dir) || !Directory.Exists(dir))
|
||||||
if (!Directory.Exists(dir))
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
string stamp = AssistentMemory.NormalizePersona(persona);
|
||||||
foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
|
foreach (string file in Directory.GetFiles(dir, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string raw = File.ReadAllText(file, Encoding.UTF8);
|
string raw = File.ReadAllText(file, Encoding.UTF8);
|
||||||
JToken parsed = JToken.Parse(raw);
|
JToken parsed = JToken.Parse(raw);
|
||||||
if (parsed is JArray arr)
|
IEnumerable<JObject> items = parsed is JArray arr
|
||||||
|
? arr.OfType<JObject>()
|
||||||
|
: parsed is JObject single ? new[] { single } : [];
|
||||||
|
foreach (JObject jo in items)
|
||||||
{
|
{
|
||||||
foreach (JToken t in arr)
|
JObject clone = (JObject)jo.DeepClone();
|
||||||
|
if (clone["persona"] is null || string.IsNullOrWhiteSpace(clone["persona"]?.ToString()))
|
||||||
{
|
{
|
||||||
if (t is JObject jo)
|
clone["persona"] = stamp;
|
||||||
{
|
|
||||||
docs.Add(jo);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
docs.Add(clone);
|
||||||
else if (parsed is JObject single)
|
|
||||||
{
|
|
||||||
docs.Add(single);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -678,9 +703,15 @@ public sealed class AssistentConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Scan(Path.Combine(_bundledRoot, "_base"));
|
|
||||||
Scan(Path.Combine(_overlayRoot, "_base"));
|
Scan(Path.Combine(_bundledRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
|
||||||
Scan(Path.Combine(_overlayRoot, "memory-seed"));
|
Scan(Path.Combine(_overlayRoot, "_base", "memory-seed"), AssistentMemory.SharedPersona);
|
||||||
|
Scan(Path.Combine(_overlayRoot, "memory-seed"), AssistentMemory.SharedPersona);
|
||||||
|
foreach (string id in PersonaIdsUnder(_bundledRoot).Concat(PersonaIdsUnder(_overlayRoot)).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Scan(Path.Combine(_bundledRoot, "personas", id, "memory-seed"), id);
|
||||||
|
Scan(Path.Combine(_overlayRoot, "personas", id, "memory-seed"), id);
|
||||||
|
}
|
||||||
return docs;
|
return docs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,829 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FreneticUtilities.FreneticExtensions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
using SwarmUI.Core;
|
||||||
|
using SwarmUI.Text2Image;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
using SwarmUI.WebAPI;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Server-side model inventory, assistant cards and Civitai lookups.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
const int MaxLorasInInventoryFallback = 150;
|
||||||
|
const int MaxWildcardsInInventoryFallback = 80;
|
||||||
|
const int MaxCheckpointsInInventoryFallback = 60;
|
||||||
|
const int InventoryBlurbMaxFallback = 140;
|
||||||
|
|
||||||
|
static string ModelWeightPath(string setName, string modelName)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modelName) || !Program.T2IModelSets.TryGetValue(setName, out T2IModelHandler handler))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!handler.Models.TryGetValue(modelName, out T2IModel model) && !handler.Models.TryGetValue(modelName.Replace('\\', '/'), out model))
|
||||||
|
{
|
||||||
|
// Try suffix match
|
||||||
|
model = handler.Models.Values.FirstOrDefault(m =>
|
||||||
|
string.Equals(m.Name, modelName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| m.Name.EndsWith("/" + modelName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| Path.GetFileNameWithoutExtension(m.Name) == Path.GetFileNameWithoutExtension(modelName));
|
||||||
|
}
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// SwarmUI T2IModel exposes RawFilePath in recent builds.
|
||||||
|
return model.RawFilePath;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static string CardPathForWeight(string weightPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(weightPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string dir = Path.GetDirectoryName(weightPath);
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(weightPath);
|
||||||
|
if (string.IsNullOrWhiteSpace(dir) || string.IsNullOrWhiteSpace(stem))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Path.Combine(dir, $"{stem}.assistent.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
static string SetNameForKind(string kind)
|
||||||
|
{
|
||||||
|
return (kind ?? "").Trim().ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"lora" => "LoRA",
|
||||||
|
"checkpoint" or "ckpt" or "stable-diffusion" => "Stable-Diffusion",
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject ReadCardObject(string kind, string name)
|
||||||
|
{
|
||||||
|
string set = SetNameForKind(kind);
|
||||||
|
string weight = ModelWeightPath(set, name);
|
||||||
|
string card = CardPathForWeight(weight);
|
||||||
|
if (card is null || !File.Exists(card))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JObject.Parse(File.ReadAllText(card, Encoding.UTF8));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentGetCard(Session session, string kind, string name)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "kind and name required" };
|
||||||
|
}
|
||||||
|
JObject card = ReadCardObject(kind, name);
|
||||||
|
string set = SetNameForKind(kind);
|
||||||
|
string weight = ModelWeightPath(set, name);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["kind"] = kind,
|
||||||
|
["name"] = name,
|
||||||
|
["has_card"] = card is not null,
|
||||||
|
["weight_path"] = weight,
|
||||||
|
["card"] = card,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentSaveCard(Session session, string kind, string name, JObject card, bool enqueue_wanted = false)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "card required" };
|
||||||
|
}
|
||||||
|
kind = (kind ?? card["kind"]?.ToString() ?? "").Trim();
|
||||||
|
name = (name ?? card["name"]?.ToString() ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "kind and name required" };
|
||||||
|
}
|
||||||
|
card["kind"] = kind;
|
||||||
|
card["name"] = name;
|
||||||
|
|
||||||
|
string set = SetNameForKind(kind);
|
||||||
|
string weight = ModelWeightPath(set, name);
|
||||||
|
if (!string.IsNullOrWhiteSpace(weight) && File.Exists(weight))
|
||||||
|
{
|
||||||
|
string path = CardPathForWeight(weight);
|
||||||
|
File.WriteAllText(path, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
|
_ = IngestCardToMemory(card, name);
|
||||||
|
return new JObject { ["success"] = true, ["path"] = path, ["installed"] = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not installed — draft into wanted-cards + optionally enqueue download for next up.
|
||||||
|
Directory.CreateDirectory(WantedCardsDir());
|
||||||
|
string rawVid = card["version_id"]?.ToString() ?? "draft";
|
||||||
|
string vid = Regex.IsMatch(rawVid, @"^\d+$") ? rawVid : "draft";
|
||||||
|
string draft = Path.Combine(WantedCardsDir(), $"{vid}.assistent.json");
|
||||||
|
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
|
if (enqueue_wanted || !string.IsNullOrWhiteSpace(card["civitai_url"]?.ToString()))
|
||||||
|
{
|
||||||
|
await AssistentEnqueueWanted(session, kind, card["civitai_url"]?.ToString(), card["version_id"]?.Value<int?>() ?? 0, card["title"]?.ToString() ?? name, card);
|
||||||
|
}
|
||||||
|
_ = IngestCardToMemory(card, name);
|
||||||
|
return new JObject { ["success"] = true, ["path"] = draft, ["installed"] = false, ["wanted"] = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task IngestCardToMemory(JObject card, string name)
|
||||||
|
{
|
||||||
|
if (Memory is null || card is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string kind = (card["kind"]?.ToString() ?? "lora").Trim().ToLowerInvariant();
|
||||||
|
string key = (card["name"]?.ToString() ?? name ?? "").Trim();
|
||||||
|
List<string> bits = [];
|
||||||
|
foreach (string field in new[] { "when", "avoid", "prompt_hint", "notes" })
|
||||||
|
{
|
||||||
|
string v = card[field]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(v))
|
||||||
|
{
|
||||||
|
bits.Add($"{field}: {v.Trim()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (card["triggers"] is JArray tr)
|
||||||
|
{
|
||||||
|
string joined = string.Join(", ", tr.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||||
|
if (!string.IsNullOrWhiteSpace(joined))
|
||||||
|
{
|
||||||
|
bits.Add("triggers: " + joined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits.Count == 0 || string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string text = $"{kind} {key}. " + string.Join(" ", bits);
|
||||||
|
string baseUrl = NormalizeBaseUrl(Config.LoadSettings()["base_url"]?.ToString());
|
||||||
|
string embedModel = Config.LoadSettings()["embed_model"]?.ToString()
|
||||||
|
?? Config.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString();
|
||||||
|
await Memory.UpsertTextAsync(baseUrl, "card", key, text, "user", card, embedModel, AssistentMemory.SharedPersona);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"IngestCardToMemory: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentGetCardMeta(Session session, string kind, string name, int version_id = 0, bool fetch = false)
|
||||||
|
{
|
||||||
|
string set = SetNameForKind(kind);
|
||||||
|
string weight = ModelWeightPath(set, name);
|
||||||
|
JObject civitai = null;
|
||||||
|
JArray exampleUrls = [];
|
||||||
|
JArray previewUrls = [];
|
||||||
|
bool hasSidecar = false;
|
||||||
|
string fetchError = null;
|
||||||
|
bool fetched = false;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(weight))
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(weight);
|
||||||
|
string dir = Path.GetDirectoryName(weight);
|
||||||
|
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
|
||||||
|
if (File.Exists(side))
|
||||||
|
{
|
||||||
|
hasSidecar = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
civitai = JObject.Parse(File.ReadAllText(side, Encoding.UTF8));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
|
||||||
|
{
|
||||||
|
string prev = Path.Combine(dir ?? "", stem + suffix);
|
||||||
|
if (File.Exists(prev))
|
||||||
|
{
|
||||||
|
// Swarm View path — relative URL works in the same origin browser session.
|
||||||
|
previewUrls.Add($"View/Models/{(kind == "lora" ? "Lora" : "Stable-Diffusion")}/{Path.GetFileName(prev)}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (civitai is not null)
|
||||||
|
{
|
||||||
|
if (version_id <= 0)
|
||||||
|
{
|
||||||
|
version_id = civitai["id"]?.Value<int?>() ?? 0;
|
||||||
|
}
|
||||||
|
CollectExampleUrls(civitai, exampleUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
string hash = null;
|
||||||
|
string trigger = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Program.T2IModelSets.TryGetValue(set, out T2IModelHandler h)
|
||||||
|
&& (h.Models.TryGetValue(name, out T2IModel m)
|
||||||
|
|| h.Models.TryGetValue(name.Replace('\\', '/'), out m)))
|
||||||
|
{
|
||||||
|
trigger = m.Metadata?.TriggerPhrase;
|
||||||
|
hash = m.Metadata?.Hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetch && civitai is null)
|
||||||
|
{
|
||||||
|
string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
fetchError = "Civitai: нет ключа в User Settings";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject remote = null;
|
||||||
|
if (version_id > 0)
|
||||||
|
{
|
||||||
|
remote = await FetchCivitaiModelVersion(apiKey, version_id);
|
||||||
|
}
|
||||||
|
if (remote is null && !string.IsNullOrWhiteSpace(hash))
|
||||||
|
{
|
||||||
|
string sha = hash.Trim().ToLowerInvariant();
|
||||||
|
if (sha.StartsWith("sha256:"))
|
||||||
|
{
|
||||||
|
sha = sha["sha256:".Length..];
|
||||||
|
}
|
||||||
|
if (sha.Length == 64)
|
||||||
|
{
|
||||||
|
remote = await FetchCivitaiByHash(apiKey, sha);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
fetchError ??= "Civitai: хеш модели не SHA256";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remote is not null)
|
||||||
|
{
|
||||||
|
civitai = remote;
|
||||||
|
fetched = true;
|
||||||
|
version_id = remote["id"]?.Value<int?>() ?? version_id;
|
||||||
|
CollectExampleUrls(remote, exampleUrls);
|
||||||
|
if (!string.IsNullOrWhiteSpace(weight))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(weight);
|
||||||
|
string dir = Path.GetDirectoryName(weight);
|
||||||
|
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
|
||||||
|
File.WriteAllText(side, remote.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
|
hasSidecar = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentGetCardMeta write sidecar: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (fetchError is null)
|
||||||
|
{
|
||||||
|
fetchError = string.IsNullOrWhiteSpace(hash)
|
||||||
|
? "Civitai: нет hash и version_id"
|
||||||
|
: "Хеш не найден на Civitai";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
fetchError = $"Civitai: {ex.Message}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject card = ReadCardObject(kind, name);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["kind"] = kind,
|
||||||
|
["name"] = name,
|
||||||
|
["version_id"] = version_id,
|
||||||
|
["trigger_phrase"] = trigger,
|
||||||
|
["has_card"] = card is not null,
|
||||||
|
["has_sidecar"] = hasSidecar,
|
||||||
|
["fetched"] = fetched,
|
||||||
|
["fetch_error"] = fetchError,
|
||||||
|
["card"] = card,
|
||||||
|
["civitai"] = civitai,
|
||||||
|
["example_urls"] = exampleUrls,
|
||||||
|
["preview_urls"] = previewUrls,
|
||||||
|
["weight_path"] = weight,
|
||||||
|
["hash"] = hash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static void CollectExampleUrls(JObject civitai, JArray exampleUrls)
|
||||||
|
{
|
||||||
|
if (civitai?["images"] is not JArray imgs)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (JToken img in imgs.Take(6))
|
||||||
|
{
|
||||||
|
string u = img?["url"]?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(u))
|
||||||
|
{
|
||||||
|
exampleUrls.Add(u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<JObject> FetchCivitaiByHash(string apiKey, string sha)
|
||||||
|
{
|
||||||
|
string[] hosts = ["civitai.red", "civitai.com"];
|
||||||
|
Exception last = null;
|
||||||
|
foreach (string host in hosts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string url = $"https://{host}/api/v1/model-versions/by-hash/{sha}";
|
||||||
|
using HttpRequestMessage req = new(HttpMethod.Get, url);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
||||||
|
}
|
||||||
|
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
||||||
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
|
||||||
|
if ((int)resp.StatusCode is 401 or 403)
|
||||||
|
{
|
||||||
|
throw last;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return JObject.Parse(body);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not HttpRequestException && ex.Message.Contains("401"))
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
last = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (last is not null)
|
||||||
|
{
|
||||||
|
throw last;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<JObject> FetchCivitaiModelVersion(string apiKey, int versionId)
|
||||||
|
{
|
||||||
|
string[] hosts = ["civitai.red", "civitai.com"];
|
||||||
|
Exception last = null;
|
||||||
|
foreach (string host in hosts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string url = $"https://{host}/api/v1/model-versions/{versionId}";
|
||||||
|
using HttpRequestMessage req = new(HttpMethod.Get, url);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
||||||
|
}
|
||||||
|
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
||||||
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
last = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 160)}");
|
||||||
|
if ((int)resp.StatusCode is 401 or 403)
|
||||||
|
{
|
||||||
|
throw last;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return JObject.Parse(body);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
last = ex;
|
||||||
|
if (ex.Message.Contains("401") || ex.Message.Contains("403"))
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (last is not null)
|
||||||
|
{
|
||||||
|
throw last;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server-side LoRA / checkpoint / wildcard inventory (not DOM scrape).
|
||||||
|
/// Pass rescan=true after downloads so new files appear (calls Program.RefreshAllModelSets).</summary>
|
||||||
|
public async Task<JObject> AssistentListInventory(Session session, bool rescan = false)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (rescan)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Program.RefreshAllModelSets();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentListInventory rescan: {ex.Message}");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Program.ModelRefreshEvent?.Invoke();
|
||||||
|
}
|
||||||
|
catch (Exception ex2)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentListInventory ModelRefreshEvent: {ex2.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JArray loras = [];
|
||||||
|
JArray checkpoints = [];
|
||||||
|
JArray wildcards = [];
|
||||||
|
|
||||||
|
if (Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler loraHandler))
|
||||||
|
{
|
||||||
|
foreach (T2IModel model in loraHandler.Models.Values
|
||||||
|
.OrderByDescending(m => LooksLikeKreaArch(m))
|
||||||
|
.ThenBy(m => m.Name)
|
||||||
|
.Take(CfgInt("max_loras_inventory", MaxLorasInInventoryFallback)))
|
||||||
|
{
|
||||||
|
loras.Add(BuildInventoryModelEntry(model, "lora"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Program.T2IModelSets.TryGetValue("Stable-Diffusion", out T2IModelHandler ckptHandler))
|
||||||
|
{
|
||||||
|
foreach (T2IModel model in ckptHandler.Models.Values
|
||||||
|
.OrderByDescending(m => LooksLikeKreaArch(m))
|
||||||
|
.ThenBy(m => m.Name)
|
||||||
|
.Take(CfgInt("max_checkpoints_inventory", MaxCheckpointsInInventoryFallback)))
|
||||||
|
{
|
||||||
|
checkpoints.Add(BuildInventoryModelEntry(model, "checkpoint"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (string name in WildcardsHelper.ListFiles.OrderBy(n => n).Take(CfgInt("max_wildcards_inventory", MaxWildcardsInInventoryFallback)))
|
||||||
|
{
|
||||||
|
wildcards.Add(new JObject { ["name"] = name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentListInventory wildcards: {ex.Message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasCivitaiKey = !string.IsNullOrWhiteSpace(session.User.GetGenericData("civitai_api", "key"));
|
||||||
|
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["loras"] = loras,
|
||||||
|
["checkpoints"] = checkpoints,
|
||||||
|
["wildcards"] = wildcards,
|
||||||
|
["has_civitai_key"] = hasCivitaiKey,
|
||||||
|
["rescanned"] = rescan,
|
||||||
|
["inventory_at"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool LooksLikeKreaArch(T2IModel model)
|
||||||
|
{
|
||||||
|
string arch = model?.ModelClass?.ID ?? "";
|
||||||
|
string compat = model?.ModelClass?.CompatClass?.ID ?? "";
|
||||||
|
string name = model?.Name ?? "";
|
||||||
|
string blob = $"{arch} {compat} {name}".ToLowerInvariant();
|
||||||
|
return blob.Contains("krea");
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject BuildInventoryModelEntry(T2IModel model, string kind)
|
||||||
|
{
|
||||||
|
string weight = null;
|
||||||
|
try { weight = model.RawFilePath; } catch { /* ignore */ }
|
||||||
|
string cardPath = CardPathForWeight(weight);
|
||||||
|
bool hasCard = !string.IsNullOrWhiteSpace(cardPath) && File.Exists(cardPath);
|
||||||
|
|
||||||
|
string usage = model.Metadata?.UsageHint;
|
||||||
|
string desc = model.Metadata?.Description;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(desc) && !string.IsNullOrWhiteSpace(model.Description))
|
||||||
|
{
|
||||||
|
desc = model.Description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// older Swarm builds
|
||||||
|
}
|
||||||
|
|
||||||
|
string blurb = null;
|
||||||
|
if (hasCard)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject card = JObject.Parse(File.ReadAllText(cardPath, Encoding.UTF8));
|
||||||
|
string fromCard = (card["notes"] ?? card["when"] ?? card["prompt_hint"])?.ToString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(fromCard))
|
||||||
|
{
|
||||||
|
blurb = Clip(fromCard.Trim(), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignore bad card json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(blurb))
|
||||||
|
{
|
||||||
|
string raw = !string.IsNullOrWhiteSpace(usage) ? usage : desc;
|
||||||
|
if (!string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
blurb = Clip(CollapseWs(raw), CfgInt("inventory_blurb_max", InventoryBlurbMaxFallback));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
JArray tags = null;
|
||||||
|
if (model.Metadata?.Tags is { Length: > 0 } tagArr)
|
||||||
|
{
|
||||||
|
tags = new JArray(tagArr.Where(t => !string.IsNullOrWhiteSpace(t)).Take(8));
|
||||||
|
}
|
||||||
|
|
||||||
|
string trigger = model.Metadata?.TriggerPhrase;
|
||||||
|
JArray triggers = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(trigger))
|
||||||
|
{
|
||||||
|
triggers = new JArray(trigger.Split(new[] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Take(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject entry = new()
|
||||||
|
{
|
||||||
|
["name"] = model.Name,
|
||||||
|
["title"] = model.Metadata?.Title ?? model.Title ?? model.Name,
|
||||||
|
["kind"] = kind,
|
||||||
|
["trigger_phrase"] = trigger,
|
||||||
|
["architecture"] = model.ModelClass?.ID,
|
||||||
|
["compat_class"] = model.ModelClass?.CompatClass?.ID,
|
||||||
|
["hash"] = model.Metadata?.Hash ?? "",
|
||||||
|
["has_card"] = hasCard,
|
||||||
|
["krea_likely"] = LooksLikeKreaArch(model),
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(weight))
|
||||||
|
{
|
||||||
|
string stem = Path.GetFileNameWithoutExtension(weight);
|
||||||
|
string dir = Path.GetDirectoryName(weight);
|
||||||
|
string side = Path.Combine(dir ?? "", $"{stem}.civitai.json");
|
||||||
|
entry["has_sidecar"] = File.Exists(side);
|
||||||
|
foreach (string suffix in new[] { ".preview.jpg", ".preview.png", ".preview.jpeg", ".jpg", ".png", ".webp" })
|
||||||
|
{
|
||||||
|
string prev = Path.Combine(dir ?? "", stem + suffix);
|
||||||
|
if (File.Exists(prev))
|
||||||
|
{
|
||||||
|
string folder = kind == "lora" ? "Lora" : "Stable-Diffusion";
|
||||||
|
entry["preview_url"] = $"View/Models/{folder}/{Path.GetFileName(prev)}";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
entry["has_sidecar"] = false;
|
||||||
|
}
|
||||||
|
if (triggers is not null && triggers.Count > 0)
|
||||||
|
{
|
||||||
|
entry["triggers"] = triggers;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(blurb))
|
||||||
|
{
|
||||||
|
entry["blurb"] = blurb;
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(usage))
|
||||||
|
{
|
||||||
|
entry["usage_hint"] = Clip(CollapseWs(usage), 120);
|
||||||
|
}
|
||||||
|
if (tags is not null && tags.Count > 0)
|
||||||
|
{
|
||||||
|
entry["tags"] = tags;
|
||||||
|
}
|
||||||
|
string defW = model.Metadata?.LoraDefaultWeight;
|
||||||
|
if (!string.IsNullOrWhiteSpace(defW) && kind == "lora")
|
||||||
|
{
|
||||||
|
entry["default_weight"] = defW;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Search Civitai for LoRAs (prefers Krea 2 base). Uses Swarm-stored civitai_api key.</summary>
|
||||||
|
public async Task<JObject> AssistentSearchCivitai(Session session, string query, int limit = 8)
|
||||||
|
{
|
||||||
|
string q = (query ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "query is required" };
|
||||||
|
}
|
||||||
|
limit = Math.Clamp(limit, 1, 20);
|
||||||
|
string apiKey = session.User.GetGenericData("civitai_api", "key") ?? "";
|
||||||
|
HashSet<string> installedNames = CollectInstalledLoraNames();
|
||||||
|
HashSet<string> installedHashes = CollectInstalledLoraHashes();
|
||||||
|
|
||||||
|
string[] hosts = ["civitai.red", "civitai.com"];
|
||||||
|
Exception lastEx = null;
|
||||||
|
foreach (string host in hosts)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string url = $"https://{host}/api/v1/models?limit={limit}&types=LORA&query={Uri.EscapeDataString(q)}";
|
||||||
|
using HttpRequestMessage req = new(HttpMethod.Get, url);
|
||||||
|
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||||
|
{
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey.Trim());
|
||||||
|
}
|
||||||
|
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
||||||
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
lastEx = new Exception($"HTTP {(int)resp.StatusCode}: {Clip(body, 200)}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject parsed = JObject.Parse(body);
|
||||||
|
JArray items = parsed["items"] as JArray ?? [];
|
||||||
|
JArray results = [];
|
||||||
|
foreach (JToken item in items)
|
||||||
|
{
|
||||||
|
if (item is not JObject mo)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject card = BuildCivitaiCard(mo, installedNames, installedHashes);
|
||||||
|
if (card is not null)
|
||||||
|
{
|
||||||
|
results.Add(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prefer Krea-compatible first
|
||||||
|
JArray sorted = new(results.OrderByDescending(t => LooksLikeKrea(t["base_model"]?.ToString())).ThenBy(t => t["name"]?.ToString()));
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["query"] = q,
|
||||||
|
["host"] = host,
|
||||||
|
["results"] = sorted,
|
||||||
|
["has_civitai_key"] = !string.IsNullOrWhiteSpace(apiKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
lastEx = ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new JObject { ["error"] = $"Civitai search failed: {lastEx?.Message ?? "unknown"}" };
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool LooksLikeKrea(string text) => !string.IsNullOrEmpty(text) && Regex.IsMatch(text, @"krea", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
static HashSet<string> CollectInstalledLoraNames()
|
||||||
|
{
|
||||||
|
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
|
||||||
|
{
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
foreach (T2IModel m in handler.Models.Values)
|
||||||
|
{
|
||||||
|
names.Add(m.Name);
|
||||||
|
string leaf = m.Name.Replace('\\', '/').AfterLast('/');
|
||||||
|
if (!string.IsNullOrEmpty(leaf))
|
||||||
|
{
|
||||||
|
names.Add(leaf);
|
||||||
|
names.Add(Path.GetFileNameWithoutExtension(leaf));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
static HashSet<string> CollectInstalledLoraHashes()
|
||||||
|
{
|
||||||
|
HashSet<string> hashes = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (!Program.T2IModelSets.TryGetValue("LoRA", out T2IModelHandler handler))
|
||||||
|
{
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
foreach (T2IModel m in handler.Models.Values)
|
||||||
|
{
|
||||||
|
string h = m.Metadata?.Hash;
|
||||||
|
if (!string.IsNullOrWhiteSpace(h))
|
||||||
|
{
|
||||||
|
hashes.Add(h.Trim().ToLowerInvariant());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hashes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JObject BuildCivitaiCard(JObject model, HashSet<string> installedNames, HashSet<string> installedHashes)
|
||||||
|
{
|
||||||
|
string name = model["name"]?.ToString() ?? "";
|
||||||
|
JArray versions = model["modelVersions"] as JArray;
|
||||||
|
JObject ver = versions?.FirstOrDefault() as JObject;
|
||||||
|
if (ver is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string baseModel = ver["baseModel"]?.ToString() ?? "";
|
||||||
|
JArray trained = ver["trainedWords"] as JArray ?? [];
|
||||||
|
List<string> triggers = trained.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)).Take(8).ToList();
|
||||||
|
JObject file = null;
|
||||||
|
foreach (JToken f in ver["files"] as JArray ?? [])
|
||||||
|
{
|
||||||
|
if (f is JObject fo && (fo["primary"]?.Value<bool>() == true || (fo["name"]?.ToString() ?? "").EndsWith(".safetensors", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
file = fo;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file ??= (ver["files"] as JArray)?.FirstOrDefault() as JObject;
|
||||||
|
string downloadUrl = file?["downloadUrl"]?.ToString() ?? ver["downloadUrl"]?.ToString() ?? "";
|
||||||
|
string fileName = file?["name"]?.ToString() ?? "";
|
||||||
|
string sha = file?["hashes"]?["SHA256"]?.ToString() ?? file?["hashes"]?["AutoV2"]?.ToString() ?? "";
|
||||||
|
string saveName = string.IsNullOrWhiteSpace(fileName)
|
||||||
|
? Regex.Replace(name, @"[^\w\-.]+", "_").Trim('_')
|
||||||
|
: Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
|
||||||
|
bool already = false;
|
||||||
|
if (!string.IsNullOrWhiteSpace(sha) && installedHashes.Contains(sha.Trim().ToLowerInvariant()))
|
||||||
|
{
|
||||||
|
already = true;
|
||||||
|
}
|
||||||
|
else if (installedNames.Contains(saveName) || installedNames.Contains(name) || installedNames.Contains(fileName))
|
||||||
|
{
|
||||||
|
already = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["id"] = model["id"],
|
||||||
|
["version_id"] = ver["id"],
|
||||||
|
["name"] = name,
|
||||||
|
["base_model"] = baseModel,
|
||||||
|
["krea_likely"] = LooksLikeKrea(baseModel),
|
||||||
|
["triggers"] = new JArray(triggers),
|
||||||
|
["download_url"] = downloadUrl,
|
||||||
|
["file_name"] = saveName,
|
||||||
|
["sha256"] = sha,
|
||||||
|
["already_installed"] = already,
|
||||||
|
["n_sfw"] = model["nsfw"]?.Value<bool>() ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
+257
-46
@@ -11,9 +11,13 @@ using SwarmUI.Utils;
|
|||||||
|
|
||||||
namespace Mrleo1nid.SwarmAssistent;
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
/// <summary>Local SQLite vector memory with Ollama /api/embed.</summary>
|
/// <summary>Local SQLite vector memory with Ollama /api/embed.
|
||||||
|
/// Two layers: shared (persona='') is visible to every personality; personal (persona=id)
|
||||||
|
/// is not written back to shared. On retrieve, personal overwrites shared on the same kind+key.</summary>
|
||||||
public sealed class AssistentMemory : IDisposable
|
public sealed class AssistentMemory : IDisposable
|
||||||
{
|
{
|
||||||
|
public const string SharedPersona = "";
|
||||||
|
|
||||||
readonly string _dbPath;
|
readonly string _dbPath;
|
||||||
readonly HttpClient _http;
|
readonly HttpClient _http;
|
||||||
readonly object _lock = new();
|
readonly object _lock = new();
|
||||||
@@ -35,6 +39,22 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
public int Dims => _dims;
|
public int Dims => _dims;
|
||||||
public int SeedVersion => _seedVersion;
|
public int SeedVersion => _seedVersion;
|
||||||
|
|
||||||
|
public static string NormalizePersona(string raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
{
|
||||||
|
return SharedPersona;
|
||||||
|
}
|
||||||
|
string s = raw.Trim();
|
||||||
|
if (s is "_" or "*" or "shared" or "common" or "global" or "_shared")
|
||||||
|
{
|
||||||
|
return SharedPersona;
|
||||||
|
}
|
||||||
|
return AssistentConfig.SafeId(s) ?? SharedPersona;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsShared(string persona) => string.IsNullOrEmpty(NormalizePersona(persona));
|
||||||
|
|
||||||
void EnsureOpen()
|
void EnsureOpen()
|
||||||
{
|
{
|
||||||
if (_conn is not null)
|
if (_conn is not null)
|
||||||
@@ -55,22 +75,85 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
kind TEXT NOT NULL,
|
kind TEXT NOT NULL,
|
||||||
key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
|
persona TEXT NOT NULL DEFAULT '',
|
||||||
text TEXT NOT NULL,
|
text TEXT NOT NULL,
|
||||||
source TEXT NOT NULL DEFAULT 'user',
|
source TEXT NOT NULL DEFAULT 'user',
|
||||||
meta_json TEXT,
|
meta_json TEXT,
|
||||||
embedding BLOB,
|
embedding BLOB,
|
||||||
updated INTEGER NOT NULL,
|
updated INTEGER NOT NULL,
|
||||||
UNIQUE(kind, key, source)
|
UNIQUE(kind, key, source, persona)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
|
|
||||||
""";
|
""";
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
MigratePersonaColumn();
|
||||||
|
using (SqliteCommand idx = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
idx.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_persona ON memories(persona);
|
||||||
|
""";
|
||||||
|
idx.ExecuteNonQuery();
|
||||||
|
}
|
||||||
_embedModel = GetMeta("embed_model") ?? _embedModel;
|
_embedModel = GetMeta("embed_model") ?? _embedModel;
|
||||||
_ = int.TryParse(GetMeta("dims"), out _dims);
|
_ = int.TryParse(GetMeta("dims"), out _dims);
|
||||||
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
|
_ = int.TryParse(GetMeta("seed_version"), out _seedVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool HasColumn(string table, string column)
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = $"PRAGMA table_info({table})";
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MigratePersonaColumn()
|
||||||
|
{
|
||||||
|
if (HasColumn("memories", "persona"))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
using SqliteTransaction tx = _conn.BeginTransaction();
|
||||||
|
using (SqliteCommand cmd = _conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.Transaction = tx;
|
||||||
|
cmd.CommandText =
|
||||||
|
"""
|
||||||
|
CREATE TABLE memories_v2 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
persona TEXT NOT NULL DEFAULT '',
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'user',
|
||||||
|
meta_json TEXT,
|
||||||
|
embedding BLOB,
|
||||||
|
updated INTEGER NOT NULL,
|
||||||
|
UNIQUE(kind, key, source, persona)
|
||||||
|
);
|
||||||
|
INSERT INTO memories_v2 (kind, key, persona, text, source, meta_json, embedding, updated)
|
||||||
|
SELECT kind, key, '', text, source, meta_json, embedding, updated FROM memories;
|
||||||
|
DROP TABLE memories;
|
||||||
|
ALTER TABLE memories_v2 RENAME TO memories;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_memories_persona ON memories(persona);
|
||||||
|
""";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
tx.Commit();
|
||||||
|
SetMeta("schema_version", "2");
|
||||||
|
Logs.Debug("AssistentMemory: migrated sqlite to shared+personal persona column (existing rows → shared)");
|
||||||
|
}
|
||||||
|
|
||||||
string GetMeta(string key)
|
string GetMeta(string key)
|
||||||
{
|
{
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
@@ -126,6 +209,11 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
return (float)(dot / (Math.Sqrt(na) * Math.Sqrt(nb)));
|
return (float)(dot / (Math.Sqrt(na) * Math.Sqrt(nb)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static int SourceRank(string source)
|
||||||
|
{
|
||||||
|
return string.Equals(source, "user", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<float[]> EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m")
|
public async Task<float[]> EmbedAsync(string baseUrl, string model, string text, string keepAlive = "60m")
|
||||||
{
|
{
|
||||||
string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/');
|
string root = (baseUrl ?? "http://127.0.0.1:11434").TrimEnd('/');
|
||||||
@@ -154,6 +242,16 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
return floats;
|
return floats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool BundledExists(string kind, string key, string persona)
|
||||||
|
{
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT 1 FROM memories WHERE source = 'bundled' AND kind = $kind AND key = $key AND persona = $persona LIMIT 1";
|
||||||
|
cmd.Parameters.AddWithValue("$kind", kind);
|
||||||
|
cmd.Parameters.AddWithValue("$key", key);
|
||||||
|
cmd.Parameters.AddWithValue("$persona", persona ?? SharedPersona);
|
||||||
|
return cmd.ExecuteScalar() is not null;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task EnsureSeedAsync(string baseUrl, AssistentConfig config, string modelOverride = null)
|
public async Task EnsureSeedAsync(string baseUrl, AssistentConfig config, string modelOverride = null)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@@ -167,28 +265,32 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
: modelOverride.Trim();
|
: modelOverride.Trim();
|
||||||
|
|
||||||
bool needReseed = _seedVersion != wantVersion || !string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase);
|
bool needReseed = _seedVersion != wantVersion || !string.Equals(_embedModel, wantModel, StringComparison.OrdinalIgnoreCase);
|
||||||
if (!needReseed)
|
|
||||||
{
|
|
||||||
int bundledCount;
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT COUNT(*) FROM memories WHERE source = 'bundled'";
|
|
||||||
bundledCount = Convert.ToInt32(cmd.ExecuteScalar());
|
|
||||||
}
|
|
||||||
if (bundledCount > 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<JObject> docs = config.LoadMemorySeedDocs();
|
List<JObject> docs = config.LoadMemorySeedDocs();
|
||||||
if (docs.Count == 0)
|
if (docs.Count == 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Probe embed
|
if (!needReseed)
|
||||||
|
{
|
||||||
|
bool missing;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
missing = docs.Any(d =>
|
||||||
|
{
|
||||||
|
string kind = (d["kind"]?.ToString() ?? "note").Trim().ToLowerInvariant();
|
||||||
|
string key = (d["key"]?.ToString() ?? "").Trim();
|
||||||
|
string persona = NormalizePersona(d["persona"]?.ToString());
|
||||||
|
return !string.IsNullOrWhiteSpace(key) && !BundledExists(kind, key, persona);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!missing)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
float[] probe;
|
float[] probe;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -228,14 +330,26 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
string kind = (doc["kind"]?.ToString() ?? "note").Trim();
|
string kind = (doc["kind"]?.ToString() ?? "note").Trim();
|
||||||
string key = (doc["key"]?.ToString() ?? "").Trim();
|
string key = (doc["key"]?.ToString() ?? "").Trim();
|
||||||
string text = (doc["text"]?.ToString() ?? "").Trim();
|
string text = (doc["text"]?.ToString() ?? "").Trim();
|
||||||
|
string persona = NormalizePersona(doc["persona"]?.ToString());
|
||||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!needReseed)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
if (BundledExists(kind.ToLowerInvariant(), key, persona))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
float[] vec = await EmbedAsync(baseUrl, wantModel, text);
|
float[] vec = await EmbedAsync(baseUrl, wantModel, text);
|
||||||
Upsert(kind, key, text, "bundled", doc["tags"], vec);
|
Upsert(kind, key, text, "bundled", doc["tags"], vec, persona);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -244,7 +358,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding)
|
public void Upsert(string kind, string key, string text, string source, JToken meta, float[] embedding, string persona = null)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
@@ -253,6 +367,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
key = (key ?? "").Trim();
|
key = (key ?? "").Trim();
|
||||||
text = (text ?? "").Trim();
|
text = (text ?? "").Trim();
|
||||||
source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim();
|
source = string.IsNullOrWhiteSpace(source) ? "user" : source.Trim();
|
||||||
|
persona = NormalizePersona(persona);
|
||||||
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -268,9 +383,9 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
cmd.CommandText =
|
cmd.CommandText =
|
||||||
"""
|
"""
|
||||||
INSERT INTO memories(kind, key, text, source, meta_json, embedding, updated)
|
INSERT INTO memories(kind, key, persona, text, source, meta_json, embedding, updated)
|
||||||
VALUES($kind, $key, $text, $source, $meta, $emb, $upd)
|
VALUES($kind, $key, $persona, $text, $source, $meta, $emb, $upd)
|
||||||
ON CONFLICT(kind, key, source) DO UPDATE SET
|
ON CONFLICT(kind, key, source, persona) DO UPDATE SET
|
||||||
text = excluded.text,
|
text = excluded.text,
|
||||||
meta_json = excluded.meta_json,
|
meta_json = excluded.meta_json,
|
||||||
embedding = excluded.embedding,
|
embedding = excluded.embedding,
|
||||||
@@ -278,6 +393,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
""";
|
""";
|
||||||
cmd.Parameters.AddWithValue("$kind", kind);
|
cmd.Parameters.AddWithValue("$kind", kind);
|
||||||
cmd.Parameters.AddWithValue("$key", key);
|
cmd.Parameters.AddWithValue("$key", key);
|
||||||
|
cmd.Parameters.AddWithValue("$persona", persona);
|
||||||
cmd.Parameters.AddWithValue("$text", text);
|
cmd.Parameters.AddWithValue("$text", text);
|
||||||
cmd.Parameters.AddWithValue("$source", source);
|
cmd.Parameters.AddWithValue("$source", source);
|
||||||
cmd.Parameters.AddWithValue("$meta", meta?.ToString(Newtonsoft.Json.Formatting.None) ?? "");
|
cmd.Parameters.AddWithValue("$meta", meta?.ToString(Newtonsoft.Json.Formatting.None) ?? "");
|
||||||
@@ -287,28 +403,33 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Forget(string kind, string key, string source = null)
|
/// <summary>Delete a row in one layer. Default (persona=current) never touches shared.
|
||||||
|
/// Pass SharedPersona to forget a shared fact. Bundled rows are kept unless source is set.</summary>
|
||||||
|
public void Forget(string kind, string key, string source = null, string persona = null)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
EnsureOpen();
|
EnsureOpen();
|
||||||
|
persona = NormalizePersona(persona);
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
if (string.IsNullOrWhiteSpace(source))
|
if (string.IsNullOrWhiteSpace(source))
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source != 'bundled'";
|
cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND persona = $persona AND source != 'bundled'";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND source = $source";
|
cmd.CommandText = "DELETE FROM memories WHERE kind = $kind AND key = $key AND persona = $persona AND source = $source";
|
||||||
cmd.Parameters.AddWithValue("$source", source);
|
cmd.Parameters.AddWithValue("$source", source);
|
||||||
}
|
}
|
||||||
cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant());
|
cmd.Parameters.AddWithValue("$kind", (kind ?? "").Trim().ToLowerInvariant());
|
||||||
cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
|
cmd.Parameters.AddWithValue("$key", (key ?? "").Trim());
|
||||||
|
cmd.Parameters.AddWithValue("$persona", persona);
|
||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null)
|
/// <summary>Retrieve shared + the given persona chain. Personal overwrites shared (and parent personas) on kind+key.</summary>
|
||||||
|
public async Task<JArray> RetrieveAsync(string baseUrl, string query, int topK = 10, string modelOverride = null, IEnumerable<string> personaChain = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
{
|
{
|
||||||
@@ -330,53 +451,143 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
List<(float score, JObject row)> scored = [];
|
Dictionary<string, int> rank = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
[SharedPersona] = 0,
|
||||||
|
};
|
||||||
|
int i = 1;
|
||||||
|
foreach (string id in personaChain ?? [])
|
||||||
|
{
|
||||||
|
string p = NormalizePersona(id);
|
||||||
|
if (p == SharedPersona)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rank[p] = i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<(float score, int personaRank, int sourceRank, JObject row)> scored = [];
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
EnsureOpen();
|
EnsureOpen();
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding FROM memories WHERE embedding IS NOT NULL";
|
cmd.CommandText = "SELECT kind, key, text, source, meta_json, embedding, persona FROM memories WHERE embedding IS NOT NULL";
|
||||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
|
string persona = reader.IsDBNull(6) ? SharedPersona : reader.GetString(6) ?? SharedPersona;
|
||||||
|
if (!rank.TryGetValue(persona, out int personaRank))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
float[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
|
float[] emb = BytesToFloats(reader.IsDBNull(5) ? null : (byte[])reader.GetValue(5));
|
||||||
float score = Cosine(q, emb);
|
float score = Cosine(q, emb);
|
||||||
if (float.IsNegativeInfinity(score))
|
if (float.IsNegativeInfinity(score))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
scored.Add((score, new JObject
|
string kind = reader.GetString(0);
|
||||||
|
string key = reader.GetString(1);
|
||||||
|
string source = reader.GetString(3);
|
||||||
|
bool shared = persona == SharedPersona;
|
||||||
|
scored.Add((score, personaRank, SourceRank(source), new JObject
|
||||||
|
{
|
||||||
|
["kind"] = kind,
|
||||||
|
["key"] = key,
|
||||||
|
["text"] = reader.GetString(2),
|
||||||
|
["source"] = source,
|
||||||
|
["scope"] = shared ? "shared" : "personal",
|
||||||
|
["persona"] = shared ? "shared" : persona,
|
||||||
|
["score"] = Math.Round(score, 4),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personal (and later parents) overwrite shared on the same kind+key; user beats bundled.
|
||||||
|
Dictionary<string, (float score, int personaRank, int sourceRank, JObject row)> best = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var item in scored)
|
||||||
|
{
|
||||||
|
string id = $"{item.row["kind"]}\n{item.row["key"]}";
|
||||||
|
if (best.TryGetValue(id, out var cur))
|
||||||
|
{
|
||||||
|
if (item.personaRank < cur.personaRank
|
||||||
|
|| (item.personaRank == cur.personaRank && item.sourceRank < cur.sourceRank)
|
||||||
|
|| (item.personaRank == cur.personaRank && item.sourceRank == cur.sourceRank && item.score <= cur.score))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best[id] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JArray(best.Values.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
|
||||||
|
}
|
||||||
|
|
||||||
|
public JArray ListAll(int limit = 200)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
List<JObject> rows = [];
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories ORDER BY updated DESC LIMIT $lim";
|
||||||
|
cmd.Parameters.AddWithValue("$lim", Math.Clamp(limit, 1, 2000));
|
||||||
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
string persona = reader.IsDBNull(4) ? SharedPersona : reader.GetString(4) ?? SharedPersona;
|
||||||
|
bool shared = string.IsNullOrEmpty(persona);
|
||||||
|
rows.Add(new JObject
|
||||||
{
|
{
|
||||||
["kind"] = reader.GetString(0),
|
["kind"] = reader.GetString(0),
|
||||||
["key"] = reader.GetString(1),
|
["key"] = reader.GetString(1),
|
||||||
["text"] = reader.GetString(2),
|
["text"] = reader.GetString(2),
|
||||||
["source"] = reader.GetString(3),
|
["source"] = reader.GetString(3),
|
||||||
["score"] = Math.Round(score, 4),
|
["scope"] = shared ? "shared" : "personal",
|
||||||
}));
|
["persona"] = shared ? "shared" : persona,
|
||||||
|
["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
return new JArray(rows);
|
||||||
}
|
}
|
||||||
return new JArray(scored.OrderByDescending(s => s.score).Take(Math.Clamp(topK, 1, 30)).Select(s => s.row));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpsertTextAsync(string baseUrl, string kind, string key, string text, string source = "user", JToken meta = null, string modelOverride = null)
|
public int CountAll()
|
||||||
{
|
{
|
||||||
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
|
|
||||||
float[] vec = await EmbedAsync(baseUrl, model, text);
|
|
||||||
Upsert(kind, key, text, source, meta, vec);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ReembedAllAsync(string baseUrl, string newModel)
|
|
||||||
{
|
|
||||||
List<(string kind, string key, string text, string source, string meta)> rows = [];
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
EnsureOpen();
|
EnsureOpen();
|
||||||
using SqliteCommand cmd = _conn.CreateCommand();
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
cmd.CommandText = "SELECT kind, key, text, source, meta_json FROM memories";
|
cmd.CommandText = "SELECT COUNT(*) FROM memories";
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertTextAsync(string baseUrl, string kind, string key, string text, string source = "user", JToken meta = null, string modelOverride = null, string persona = null)
|
||||||
|
{
|
||||||
|
string model = string.IsNullOrWhiteSpace(modelOverride) ? _embedModel : modelOverride;
|
||||||
|
float[] vec = await EmbedAsync(baseUrl, model, text);
|
||||||
|
Upsert(kind, key, text, source, meta, vec, persona);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReembedAllAsync(string baseUrl, string newModel)
|
||||||
|
{
|
||||||
|
List<(string kind, string key, string text, string source, string meta, string persona)> rows = [];
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
EnsureOpen();
|
||||||
|
using SqliteCommand cmd = _conn.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT kind, key, text, source, meta_json, persona FROM memories";
|
||||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
{
|
||||||
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3), reader.IsDBNull(4) ? "" : reader.GetString(4)));
|
rows.Add((
|
||||||
|
reader.GetString(0),
|
||||||
|
reader.GetString(1),
|
||||||
|
reader.GetString(2),
|
||||||
|
reader.GetString(3),
|
||||||
|
reader.IsDBNull(4) ? "" : reader.GetString(4),
|
||||||
|
reader.FieldCount > 5 && !reader.IsDBNull(5) ? reader.GetString(5) : SharedPersona));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
@@ -408,7 +619,7 @@ public sealed class AssistentMemory : IDisposable
|
|||||||
{
|
{
|
||||||
try { meta = JToken.Parse(row.meta); } catch { /* ignore */ }
|
try { meta = JToken.Parse(row.meta); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
Upsert(row.kind, row.key, row.text, row.source, meta, vec);
|
Upsert(row.kind, row.key, row.text, row.source, meta, vec, row.persona);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Read/write routes for the vector memory list in ⚙ and the gpu-rent wanted queue badge.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
/// <summary>Embed model the UI should use: settings overlay wins, then persona assistant.json.</summary>
|
||||||
|
string MemoryEmbedModel(string requested = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(requested))
|
||||||
|
{
|
||||||
|
return requested.Trim();
|
||||||
|
}
|
||||||
|
return Config?.LoadSettings()["embed_model"]?.ToString()
|
||||||
|
?? Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString()
|
||||||
|
?? "nomic-embed-text";
|
||||||
|
}
|
||||||
|
|
||||||
|
string MemoryBaseUrl(string requested = null)
|
||||||
|
{
|
||||||
|
return NormalizeBaseUrl(string.IsNullOrWhiteSpace(requested)
|
||||||
|
? Config?.LoadSettings()["base_url"]?.ToString()
|
||||||
|
: requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
string ResolveApiPersona(string persona, string scope)
|
||||||
|
{
|
||||||
|
string s = (scope ?? "").Trim().ToLowerInvariant();
|
||||||
|
if (s is "shared" or "common" or "global")
|
||||||
|
{
|
||||||
|
return AssistentMemory.SharedPersona;
|
||||||
|
}
|
||||||
|
if (s is "personal")
|
||||||
|
{
|
||||||
|
return AssistentConfig.SafeId(persona) ?? Config?.DefaultPersonaId() ?? "neutral";
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(persona) || AssistentMemory.IsShared(persona))
|
||||||
|
{
|
||||||
|
return AssistentMemory.SharedPersona;
|
||||||
|
}
|
||||||
|
return AssistentMemory.NormalizePersona(persona);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentListMemory(Session session, int limit = 200, string kind = null, string persona = null, string scope = null)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (Memory is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "memory not ready" };
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JArray all = Memory.ListAll(limit);
|
||||||
|
string filter = (kind ?? "").Trim().ToLowerInvariant();
|
||||||
|
string wantScope = (scope ?? "").Trim().ToLowerInvariant();
|
||||||
|
string wantPersona = (persona ?? "").Trim();
|
||||||
|
IEnumerable<JToken> q = all;
|
||||||
|
if (!string.IsNullOrWhiteSpace(filter) && filter != "all")
|
||||||
|
{
|
||||||
|
q = q.Where(t => string.Equals(t?["kind"]?.ToString(), filter, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
bool personaGiven = !string.IsNullOrWhiteSpace(wantPersona);
|
||||||
|
if (wantScope is "shared" or "common" or "global"
|
||||||
|
|| (personaGiven && AssistentMemory.IsShared(wantPersona)))
|
||||||
|
{
|
||||||
|
q = q.Where(t => string.Equals(t?["scope"]?.ToString(), "shared", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
else if (wantScope is "personal" || personaGiven)
|
||||||
|
{
|
||||||
|
string pid = AssistentMemory.NormalizePersona(personaGiven ? wantPersona : Config?.DefaultPersonaId());
|
||||||
|
q = q.Where(t => string.Equals(t?["persona"]?.ToString(), pid, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
JArray rows = new(q);
|
||||||
|
JArray kinds = new(all
|
||||||
|
.Select(t => t?["kind"]?.ToString())
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase));
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["memories"] = rows,
|
||||||
|
["kinds"] = kinds,
|
||||||
|
["total"] = Memory.CountAll(),
|
||||||
|
["embed_model"] = Memory.EmbedModel,
|
||||||
|
["dims"] = Memory.Dims,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"memory list: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentUpsertMemory(Session session, string kind, string key, string text, string source = "user", string baseUrl = null, string embed_model = null, string persona = null, string scope = null)
|
||||||
|
{
|
||||||
|
if (Memory is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "memory not ready" };
|
||||||
|
}
|
||||||
|
kind = (kind ?? "note").Trim().ToLowerInvariant();
|
||||||
|
key = (key ?? "").Trim();
|
||||||
|
text = (text ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "key and text required" };
|
||||||
|
}
|
||||||
|
string src = (source ?? "user").Trim().ToLowerInvariant();
|
||||||
|
if (src == "bundled")
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "bundled memories are read-only — use memory-seed/" };
|
||||||
|
}
|
||||||
|
string target = ResolveApiPersona(persona, scope);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Memory.UpsertTextAsync(MemoryBaseUrl(baseUrl), kind, key, text, src, null, MemoryEmbedModel(embed_model), target);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["kind"] = kind,
|
||||||
|
["key"] = key,
|
||||||
|
["source"] = src,
|
||||||
|
["scope"] = AssistentMemory.IsShared(target) ? "shared" : "personal",
|
||||||
|
["persona"] = AssistentMemory.IsShared(target) ? "shared" : target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"memory upsert: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentForgetMemory(Session session, string kind, string key, string source = null, string persona = null, string scope = null)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
if (Memory is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "memory not ready" };
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(kind) || string.IsNullOrWhiteSpace(key))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "kind and key required" };
|
||||||
|
}
|
||||||
|
if (string.Equals((source ?? "").Trim(), "bundled", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "bundled memories come back on reseed — edit memory-seed/ instead" };
|
||||||
|
}
|
||||||
|
string target = ResolveApiPersona(persona, scope);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Memory.Forget(kind, key, string.IsNullOrWhiteSpace(source) ? null : source.Trim(), target);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["kind"] = kind.Trim().ToLowerInvariant(),
|
||||||
|
["key"] = key.Trim(),
|
||||||
|
["scope"] = AssistentMemory.IsShared(target) ? "shared" : "personal",
|
||||||
|
["persona"] = AssistentMemory.IsShared(target) ? "shared" : target,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"memory forget: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The gpu-rent wanted queue (models pending the next <c>up</c>) — count + entries.</summary>
|
||||||
|
public async Task<JObject> AssistentListWanted(Session session)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
string path = WantedModelsPath();
|
||||||
|
JArray items = [];
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["count"] = 0, ["items"] = items, ["path"] = path };
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.ReadAllText(path, Encoding.UTF8));
|
||||||
|
foreach ((string kind, List<WantedEntry> list) in sections.OrderBy(p => p.Key, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
foreach (WantedEntry entry in list)
|
||||||
|
{
|
||||||
|
items.Add(new JObject
|
||||||
|
{
|
||||||
|
["kind"] = kind,
|
||||||
|
["url"] = entry.Url,
|
||||||
|
["title"] = entry.Title,
|
||||||
|
["version_id"] = entry.VersionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["count"] = items.Count,
|
||||||
|
["items"] = items,
|
||||||
|
["path"] = path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"wanted queue: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
using SwarmUI.WebAPI;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Ollama transport: model listing, /api/chat calls and the two chat API endpoints.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
const int DefaultNumCtxFallback = 16384;
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentListModels(Session session, string baseUrl)
|
||||||
|
{
|
||||||
|
string root = NormalizeBaseUrl(baseUrl);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/tags");
|
||||||
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"Ollama /api/tags HTTP {(int)resp.StatusCode}: {Clip(body, 400)}" };
|
||||||
|
}
|
||||||
|
JObject parsed = JObject.Parse(body);
|
||||||
|
JArray all = [];
|
||||||
|
foreach (JToken m in parsed["models"] as JArray ?? [])
|
||||||
|
{
|
||||||
|
string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
all.Add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JObject roles = Config?.LoadOllamaRoles() ?? new JObject();
|
||||||
|
HashSet<string> chatSet = new(
|
||||||
|
(roles["chat"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
HashSet<string> memSet = new(
|
||||||
|
(roles["memory"] as JArray)?.Select(t => t?.ToString()).Where(s => !string.IsNullOrWhiteSpace(s)) ?? [],
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
// Heuristic fallbacks when sidecar missing
|
||||||
|
if (chatSet.Count == 0 && memSet.Count == 0)
|
||||||
|
{
|
||||||
|
foreach (JToken t in all)
|
||||||
|
{
|
||||||
|
string n = t.ToString();
|
||||||
|
if (LooksLikeEmbedModel(n))
|
||||||
|
{
|
||||||
|
memSet.Add(n);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
chatSet.Add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Keep only tags that exist; anything unlabeled goes to chat if not memory
|
||||||
|
foreach (JToken t in all)
|
||||||
|
{
|
||||||
|
string n = t.ToString();
|
||||||
|
if (memSet.Contains(n) || LooksLikeEmbedModel(n))
|
||||||
|
{
|
||||||
|
memSet.Add(n);
|
||||||
|
chatSet.Remove(n);
|
||||||
|
}
|
||||||
|
else if (chatSet.Count == 0 || chatSet.Contains(n))
|
||||||
|
{
|
||||||
|
chatSet.Add(n);
|
||||||
|
}
|
||||||
|
else if (!memSet.Contains(n))
|
||||||
|
{
|
||||||
|
chatSet.Add(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JArray models = new(all.Select(t => t.ToString()).Where(n => chatSet.Contains(n) && !memSet.Contains(n) && !LooksLikeEmbedModel(n)));
|
||||||
|
JArray memoryModels = new(all.Select(t => t.ToString()).Where(n => memSet.Contains(n) || LooksLikeEmbedModel(n)).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
||||||
|
if (memoryModels.Count == 0)
|
||||||
|
{
|
||||||
|
string fallback = Config?.LoadAssistant(Config.DefaultPersonaId())["embed_model"]?.ToString() ?? "nomic-embed-text";
|
||||||
|
if (all.Any(t => string.Equals(t.ToString(), fallback, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| t.ToString().StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
memoryModels.Add(all.Select(t => t.ToString()).First(n =>
|
||||||
|
string.Equals(n, fallback, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| n.StartsWith(fallback.Split(':')[0], StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["base_url"] = root,
|
||||||
|
["models"] = models,
|
||||||
|
["memory_models"] = memoryModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"Ollama unreachable at {root}: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool LooksLikeEmbedModel(string name)
|
||||||
|
{
|
||||||
|
string n = (name ?? "").ToLowerInvariant();
|
||||||
|
return n.Contains("embed") || n.Contains("nomic") || n.Contains("bge-") || n.Contains("minilm") || n.Contains("e5-");
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<(string reply, JObject raw)> CallOllamaChat(
|
||||||
|
string root,
|
||||||
|
string modelName,
|
||||||
|
List<JObject> ollamaMessages,
|
||||||
|
bool stream,
|
||||||
|
Func<string, Task> onDelta,
|
||||||
|
string personaId = null)
|
||||||
|
{
|
||||||
|
int numCtx = Config.LoadAssistant(AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId())["num_ctx"]?.Value<int?>()
|
||||||
|
?? DefaultNumCtxFallback;
|
||||||
|
JObject payload = new()
|
||||||
|
{
|
||||||
|
["model"] = modelName,
|
||||||
|
["stream"] = stream,
|
||||||
|
["messages"] = new JArray(ollamaMessages),
|
||||||
|
["options"] = new JObject
|
||||||
|
{
|
||||||
|
["num_ctx"] = numCtx,
|
||||||
|
},
|
||||||
|
["keep_alive"] = "15m",
|
||||||
|
};
|
||||||
|
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||||
|
using HttpRequestMessage req = new(HttpMethod.Post, $"{root}/api/chat") { Content = content };
|
||||||
|
using HttpResponseMessage resp = await HttpClient.SendAsync(req, stream
|
||||||
|
? HttpCompletionOption.ResponseHeadersRead
|
||||||
|
: HttpCompletionOption.ResponseContentRead);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
string errBody = await resp.Content.ReadAsStringAsync();
|
||||||
|
throw new Exception($"Ollama /api/chat HTTP {(int)resp.StatusCode}: {Clip(errBody, 800)}");
|
||||||
|
}
|
||||||
|
if (!stream)
|
||||||
|
{
|
||||||
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
JObject parsed = JObject.Parse(body);
|
||||||
|
string reply = parsed["message"]?["content"]?.ToString() ?? parsed["response"]?.ToString() ?? "";
|
||||||
|
return (reply, parsed);
|
||||||
|
}
|
||||||
|
StringBuilder full = new();
|
||||||
|
await using Stream streamBody = await resp.Content.ReadAsStreamAsync();
|
||||||
|
using StreamReader reader = new(streamBody, Encoding.UTF8);
|
||||||
|
JObject last = null;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
string line = await reader.ReadLineAsync();
|
||||||
|
if (line is null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(line))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject chunk = JObject.Parse(line);
|
||||||
|
last = chunk;
|
||||||
|
string delta = chunk["message"]?["content"]?.ToString() ?? "";
|
||||||
|
if (!string.IsNullOrEmpty(delta))
|
||||||
|
{
|
||||||
|
full.Append(delta);
|
||||||
|
if (onDelta is not null)
|
||||||
|
{
|
||||||
|
await onDelta(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk["done"]?.Value<bool>() == true)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (full.ToString(), last ?? new JObject());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>SwarmUI hands the whole request body over as the JObject param, so every field is read flat off it.</summary>
|
||||||
|
static void ExtractChatPayload(JObject raw, ref string baseUrl, ref string model, ref string pack, ref bool includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills)
|
||||||
|
{
|
||||||
|
JObject whole = raw ?? [];
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
{
|
||||||
|
baseUrl = whole["base_url"]?.ToString() ?? whole["baseUrl"]?.ToString();
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(model))
|
||||||
|
{
|
||||||
|
model = whole["model"]?.ToString();
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(pack))
|
||||||
|
{
|
||||||
|
pack = whole["pack"]?.ToString();
|
||||||
|
}
|
||||||
|
if (whole["includeBase"] is not null)
|
||||||
|
{
|
||||||
|
includeBase = whole.Value<bool?>("includeBase") ?? includeBase;
|
||||||
|
}
|
||||||
|
userMessages = whole["messages"] as JArray;
|
||||||
|
contextJson = whole["context_json"]?.ToString();
|
||||||
|
persona = whole["persona"]?.ToString() ?? "neutral";
|
||||||
|
skills = whole["skills"] as JArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared validation for both chat endpoints. Returns an error message, or null when the request is usable.</summary>
|
||||||
|
static string ValidateChatRequest(string modelName, JArray userMessages)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(modelName))
|
||||||
|
{
|
||||||
|
return "model is required";
|
||||||
|
}
|
||||||
|
if (userMessages is null || userMessages.Count == 0)
|
||||||
|
{
|
||||||
|
return "messages required";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Proxy to Ollama /api/chat (non-stream), with optional Civitai search hop.</summary>
|
||||||
|
public async Task<JObject> AssistentChat(Session session, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
||||||
|
{
|
||||||
|
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
||||||
|
string root = NormalizeBaseUrl(baseUrl);
|
||||||
|
string modelName = (model ?? "").Trim();
|
||||||
|
string invalid = ValidateChatRequest(modelName, userMessages);
|
||||||
|
if (invalid is not null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = invalid };
|
||||||
|
}
|
||||||
|
string packName = (pack ?? "write_prompt").Trim();
|
||||||
|
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
|
||||||
|
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["reply"] = reply,
|
||||||
|
["model"] = modelName,
|
||||||
|
["pack"] = packName,
|
||||||
|
["persona"] = persona,
|
||||||
|
["raw"] = parsed,
|
||||||
|
["civitai_results"] = civitai,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>WebSocket streaming chat (Ollama stream:true) + Civitai hops.</summary>
|
||||||
|
public async Task<JObject> AssistentChatWS(Session session, WebSocket ws, string baseUrl, string model, string pack, bool includeBase, JObject raw)
|
||||||
|
{
|
||||||
|
ExtractChatPayload(raw, ref baseUrl, ref model, ref pack, ref includeBase, out JArray userMessages, out string contextJson, out string persona, out JArray skills);
|
||||||
|
string root = NormalizeBaseUrl(baseUrl);
|
||||||
|
string modelName = (model ?? "").Trim();
|
||||||
|
string invalid = ValidateChatRequest(modelName, userMessages);
|
||||||
|
if (invalid is not null)
|
||||||
|
{
|
||||||
|
await ws.SendJson(new JObject { ["error"] = invalid }, API.WebsocketTimeout);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string packName = (pack ?? "write_prompt").Trim();
|
||||||
|
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (ws.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
await ws.SendJson(new JObject
|
||||||
|
{
|
||||||
|
["phase"] = "waiting_ollama",
|
||||||
|
["notice"] = "Loading model into GPU…",
|
||||||
|
}, API.WebsocketTimeout);
|
||||||
|
}
|
||||||
|
async Task OnDelta(string delta)
|
||||||
|
{
|
||||||
|
if (ws.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
await ws.SendJson(new JObject { ["delta"] = delta }, API.WebsocketTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async Task OnHopStart(int hop)
|
||||||
|
{
|
||||||
|
if (ws.State == WebSocketState.Open && hop > 0)
|
||||||
|
{
|
||||||
|
await ws.SendJson(new JObject
|
||||||
|
{
|
||||||
|
["clear_stream"] = true,
|
||||||
|
["hop"] = hop + 1,
|
||||||
|
["notice"] = "Civitai search done — refining…",
|
||||||
|
}, API.WebsocketTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(string reply, JObject parsed, JArray civitai) = await RunChatWithHops(
|
||||||
|
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
|
||||||
|
await ws.SendJson(new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["done"] = true,
|
||||||
|
["reply"] = reply,
|
||||||
|
["model"] = modelName,
|
||||||
|
["pack"] = packName,
|
||||||
|
["persona"] = persona,
|
||||||
|
["raw"] = parsed,
|
||||||
|
["civitai_results"] = civitai,
|
||||||
|
}, API.WebsocketTimeout);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await ws.SendJson(new JObject { ["error"] = $"Ollama chat failed: {ex.Message}" }, API.WebsocketTimeout);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Parsing and normalization of the JSON patch the model emits inside fenced code blocks.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
static readonly Regex JsonFenceRe = new(@"```(?:json)?\s*([\s\S]*?)```", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
static readonly string[] PatchKeys =
|
||||||
|
[
|
||||||
|
"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", "memories", "memory",
|
||||||
|
];
|
||||||
|
|
||||||
|
static bool HasValue(JObject obj, string key)
|
||||||
|
{
|
||||||
|
JToken token = obj?[key];
|
||||||
|
return token is not null && token.Type != JTokenType.Null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps legacy/alias patch fields onto their canonical names. Aliases are kept so older consumers still work.</summary>
|
||||||
|
public static JObject NormalizePatch(JObject patch)
|
||||||
|
{
|
||||||
|
if (patch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!HasValue(patch, "search_query") && HasValue(patch, "civitai_query"))
|
||||||
|
{
|
||||||
|
patch["search_query"] = patch["civitai_query"];
|
||||||
|
}
|
||||||
|
if (!HasValue(patch, "init_creativity") && HasValue(patch, "denoise"))
|
||||||
|
{
|
||||||
|
patch["init_creativity"] = patch["denoise"];
|
||||||
|
}
|
||||||
|
if (!HasValue(patch, "look_at"))
|
||||||
|
{
|
||||||
|
if (HasValue(patch, "vision_from"))
|
||||||
|
{
|
||||||
|
patch["look_at"] = patch["vision_from"];
|
||||||
|
}
|
||||||
|
else if (HasValue(patch, "vision_slots"))
|
||||||
|
{
|
||||||
|
patch["look_at"] = patch["vision_slots"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JObject TryParsePatch(string reply)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(reply))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach (Match match in JsonFenceRe.Matches(reply))
|
||||||
|
{
|
||||||
|
string raw = match.Groups[1].Value.Trim();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject obj = JObject.Parse(raw);
|
||||||
|
if (obj is not null && Array.Exists(PatchKeys, k => obj[k] is not null))
|
||||||
|
{
|
||||||
|
return NormalizePatch(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// not json
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string ExtractSearchQuery(JObject patch)
|
||||||
|
{
|
||||||
|
if (patch is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
string q = (patch["search_query"] ?? patch["civitai_query"])?.ToString()?.Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(q) ? null : q;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool WantsCivitaiSearch(JObject patch)
|
||||||
|
{
|
||||||
|
return !string.IsNullOrWhiteSpace(ExtractSearchQuery(patch));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Disk persistence for chat sessions and UI state under <c>DataRoot()/Assistent/</c>.
|
||||||
|
/// Chats survive browser storage wipes and follow the data volume across gpu-rent VMs.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
const int MaxChatsOnDisk = 60;
|
||||||
|
const int MaxChatMessagesOnDisk = 40;
|
||||||
|
const int MaxChatMessageChars = 4000;
|
||||||
|
|
||||||
|
static readonly Regex ChatIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
/// <summary>UI-state keys accepted from the browser — anything else is dropped.</summary>
|
||||||
|
static readonly string[] UiStateKeys =
|
||||||
|
[
|
||||||
|
"pack", "persona", "auto_vision", "auto_apply", "auto_generate", "auto_critique",
|
||||||
|
"auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab",
|
||||||
|
];
|
||||||
|
|
||||||
|
string AssistentDataDir() => Path.Combine(DataRoot(), "Assistent");
|
||||||
|
|
||||||
|
string AssistentChatsDir() => Path.Combine(AssistentDataDir(), "chats");
|
||||||
|
|
||||||
|
string AssistentUiStatePath() => Path.Combine(AssistentDataDir(), "ui-state.json");
|
||||||
|
|
||||||
|
static string SafeChatId(string id)
|
||||||
|
{
|
||||||
|
string s = (id ?? "").Trim();
|
||||||
|
return ChatIdRe.IsMatch(s) ? s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
string ChatFilePath(string id)
|
||||||
|
{
|
||||||
|
string safe = SafeChatId(id);
|
||||||
|
return safe is null ? null : Path.Combine(AssistentChatsDir(), $"{safe}.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
JObject ReadChatFile(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JObject chat = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
|
||||||
|
string id = SafeChatId(chat["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(path));
|
||||||
|
if (id is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
chat["id"] = id;
|
||||||
|
return chat;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentPersist read {path}: {ex.Message}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static JObject ChatSummary(JObject chat)
|
||||||
|
{
|
||||||
|
JArray messages = chat["messages"] as JArray ?? [];
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["id"] = chat["id"],
|
||||||
|
["title"] = chat["title"]?.ToString() ?? "Новый чат",
|
||||||
|
["createdAt"] = chat["createdAt"] ?? 0,
|
||||||
|
["updatedAt"] = chat["updatedAt"] ?? 0,
|
||||||
|
["messages_count"] = messages.Count,
|
||||||
|
["params"] = chat["params"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
List<JObject> LoadAllChats()
|
||||||
|
{
|
||||||
|
string dir = AssistentChatsDir();
|
||||||
|
if (!Directory.Exists(dir))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
List<JObject> chats = [];
|
||||||
|
foreach (string file in Directory.EnumerateFiles(dir, "*.json"))
|
||||||
|
{
|
||||||
|
JObject chat = ReadChatFile(file);
|
||||||
|
if (chat is not null)
|
||||||
|
{
|
||||||
|
chats.Add(chat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chats
|
||||||
|
.OrderByDescending(c => c["updatedAt"]?.Value<long?>() ?? 0)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>All chats on disk, newest first. Pass <c>with_messages</c> to get full transcripts.</summary>
|
||||||
|
public async Task<JObject> AssistentListChats(Session session, bool with_messages = false, int limit = MaxChatsOnDisk)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<JObject> chats = LoadAllChats();
|
||||||
|
int take = Math.Clamp(limit, 1, MaxChatsOnDisk);
|
||||||
|
JArray list = [];
|
||||||
|
foreach (JObject chat in chats.Take(take))
|
||||||
|
{
|
||||||
|
list.Add(with_messages ? chat : ChatSummary(chat));
|
||||||
|
}
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["chats"] = list,
|
||||||
|
["total"] = chats.Count,
|
||||||
|
["path"] = AssistentChatsDir(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"chats list: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentGetChat(Session session, string id)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
string path = ChatFilePath(id);
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "valid id required" };
|
||||||
|
}
|
||||||
|
JObject chat = ReadChatFile(path);
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["id"] = SafeChatId(id),
|
||||||
|
["found"] = chat is not null,
|
||||||
|
["chat"] = chat,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes one chat to <c>Assistent/chats/<id>.json</c>.
|
||||||
|
/// SwarmUI hands the whole request body to a JObject param, so <c>messages</c> (array)
|
||||||
|
/// and <c>params</c> (object) are read out of <paramref name="raw"/>.</summary>
|
||||||
|
public async Task<JObject> AssistentSaveChat(Session session, string id, string title, JObject raw)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
string safe = SafeChatId(id);
|
||||||
|
if (safe is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "valid id required" };
|
||||||
|
}
|
||||||
|
JArray messages = raw?["messages"] as JArray ?? [];
|
||||||
|
JObject chatParams = raw?["params"] as JObject;
|
||||||
|
JArray trimmed = [];
|
||||||
|
foreach (JToken msg in messages.Skip(Math.Max(0, messages.Count - MaxChatMessagesOnDisk)))
|
||||||
|
{
|
||||||
|
if (msg is not JObject mo)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JObject copy = new()
|
||||||
|
{
|
||||||
|
["role"] = mo["role"]?.ToString() ?? "user",
|
||||||
|
["content"] = Clip(mo["content"]?.ToString() ?? "", MaxChatMessageChars),
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(mo["persona"]?.ToString()))
|
||||||
|
{
|
||||||
|
copy["persona"] = mo["persona"];
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(mo["pack"]?.ToString()))
|
||||||
|
{
|
||||||
|
copy["pack"] = mo["pack"];
|
||||||
|
}
|
||||||
|
trimmed.Add(copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
string path = ChatFilePath(safe);
|
||||||
|
JObject existing = ReadChatFile(path);
|
||||||
|
JObject chat = new()
|
||||||
|
{
|
||||||
|
["id"] = safe,
|
||||||
|
["title"] = string.IsNullOrWhiteSpace(title) ? (existing?["title"]?.ToString() ?? "Новый чат") : title.Trim(),
|
||||||
|
["createdAt"] = raw?["createdAt"]?.Value<long?>() ?? existing?["createdAt"]?.Value<long?>() ?? now,
|
||||||
|
["updatedAt"] = raw?["updatedAt"]?.Value<long?>() ?? now,
|
||||||
|
["messages"] = trimmed,
|
||||||
|
["params"] = chatParams ?? existing?["params"],
|
||||||
|
};
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(AssistentChatsDir());
|
||||||
|
File.WriteAllText(path, chat.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
|
PruneChatsOnDisk();
|
||||||
|
return new JObject { ["success"] = true, ["id"] = safe, ["path"] = path };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"chat save: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentDeleteChat(Session session, string id)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
string path = ChatFilePath(id);
|
||||||
|
if (path is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "valid id required" };
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool existed = File.Exists(path);
|
||||||
|
if (existed)
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
return new JObject { ["success"] = true, ["deleted"] = existed, ["id"] = SafeChatId(id) };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"chat delete: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PruneChatsOnDisk()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<JObject> chats = LoadAllChats();
|
||||||
|
if (chats.Count <= MaxChatsOnDisk)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach (JObject stale in chats.Skip(MaxChatsOnDisk))
|
||||||
|
{
|
||||||
|
string path = ChatFilePath(stale["id"]?.ToString());
|
||||||
|
if (path is not null && File.Exists(path))
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentPersist prune: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentGetUiState(Session session)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
string path = AssistentUiStatePath();
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["ui_state"] = null };
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["ui_state"] = JObject.Parse(File.ReadAllText(path, Encoding.UTF8)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"ui-state.json: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persists the whitelisted UI preferences. Reads <c>ui_state</c> from the body,
|
||||||
|
/// falling back to the flat body for convenience.</summary>
|
||||||
|
public async Task<JObject> AssistentSaveUiState(Session session, JObject raw)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
JObject source = raw?["ui_state"] as JObject ?? raw;
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "ui_state required" };
|
||||||
|
}
|
||||||
|
JObject clean = new();
|
||||||
|
foreach (string key in UiStateKeys)
|
||||||
|
{
|
||||||
|
JToken value = source[key];
|
||||||
|
if (value is null || value.Type == JTokenType.Null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clean[key] = value.DeepClone();
|
||||||
|
}
|
||||||
|
if (clean.Count == 0)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "ui_state has no known keys" };
|
||||||
|
}
|
||||||
|
clean["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(AssistentDataDir());
|
||||||
|
string path = AssistentUiStatePath();
|
||||||
|
File.WriteAllText(path, clean.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
|
return new JObject { ["success"] = true, ["path"] = path };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = $"ui-state save: {ex.Message}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
using SwarmUI.Utils;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>VRAM handover between Ollama and the image backend: park the chat model before
|
||||||
|
/// Generate, warm it again once the user is back in the chat. Embed / memory models are never
|
||||||
|
/// parked — they are tiny and reloading them stalls every retrieve.</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
const string WarmKeepAlive = "15m";
|
||||||
|
|
||||||
|
/// <summary>Unloads the chat model from VRAM (<c>keep_alive: 0</c>) so Krea 2 gets the whole GPU.</summary>
|
||||||
|
public async Task<JObject> AssistentParkLlm(Session session, string baseUrl, string model)
|
||||||
|
{
|
||||||
|
string root = NormalizeBaseUrl(baseUrl);
|
||||||
|
string name = (model ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "model is required" };
|
||||||
|
}
|
||||||
|
if (LooksLikeEmbedModel(name))
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "memory model — never parked" };
|
||||||
|
}
|
||||||
|
JObject generate = new()
|
||||||
|
{
|
||||||
|
["model"] = name,
|
||||||
|
["prompt"] = "",
|
||||||
|
["stream"] = false,
|
||||||
|
["keep_alive"] = 0,
|
||||||
|
};
|
||||||
|
(bool ok, string body) = await PostOllamaJson(root, "/api/generate", generate);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
// Older Ollama builds only unload through /api/chat.
|
||||||
|
JObject chat = new()
|
||||||
|
{
|
||||||
|
["model"] = name,
|
||||||
|
["messages"] = new JArray(),
|
||||||
|
["stream"] = false,
|
||||||
|
["keep_alive"] = 0,
|
||||||
|
};
|
||||||
|
(ok, body) = await PostOllamaJson(root, "/api/chat", chat);
|
||||||
|
}
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentParkLlm {name}: {Clip(body, 200)}");
|
||||||
|
return new JObject { ["success"] = true, ["parked"] = false, ["note"] = Clip(body, 200) };
|
||||||
|
}
|
||||||
|
return new JObject { ["success"] = true, ["parked"] = true, ["model"] = name, ["base_url"] = root };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Single-token chat so the model is resident again by the time the user types.</summary>
|
||||||
|
public async Task<JObject> AssistentWarmLlm(Session session, string baseUrl, string model, string persona = null)
|
||||||
|
{
|
||||||
|
string root = NormalizeBaseUrl(baseUrl);
|
||||||
|
string name = (model ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "model is required" };
|
||||||
|
}
|
||||||
|
if (LooksLikeEmbedModel(name))
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["warmed"] = false, ["skipped"] = "memory model" };
|
||||||
|
}
|
||||||
|
int numCtx = CfgInt("num_ctx", DefaultNumCtxFallback);
|
||||||
|
JObject payload = new()
|
||||||
|
{
|
||||||
|
["model"] = name,
|
||||||
|
["stream"] = false,
|
||||||
|
["messages"] = new JArray
|
||||||
|
{
|
||||||
|
new JObject { ["role"] = "user", ["content"] = "ok" },
|
||||||
|
},
|
||||||
|
["options"] = new JObject
|
||||||
|
{
|
||||||
|
["num_ctx"] = numCtx,
|
||||||
|
["num_predict"] = 1,
|
||||||
|
},
|
||||||
|
["keep_alive"] = WarmKeepAlive,
|
||||||
|
};
|
||||||
|
(bool ok, string body) = await PostOllamaJson(root, "/api/chat", payload);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
Logs.Debug($"AssistentWarmLlm {name}: {Clip(body, 200)}");
|
||||||
|
return new JObject { ["success"] = true, ["warmed"] = false, ["note"] = Clip(body, 200) };
|
||||||
|
}
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["warmed"] = true,
|
||||||
|
["model"] = name,
|
||||||
|
["num_ctx"] = numCtx,
|
||||||
|
["keep_alive"] = WarmKeepAlive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<(bool ok, string body)> PostOllamaJson(string root, string route, JObject payload)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||||
|
using HttpResponseMessage resp = await HttpClient.PostAsync($"{root}{route}", content);
|
||||||
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
return (resp.IsSuccessStatusCode, resp.IsSuccessStatusCode ? body : $"HTTP {(int)resp.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (false, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using SwarmUI.Accounts;
|
||||||
|
|
||||||
|
namespace Mrleo1nid.SwarmAssistent;
|
||||||
|
|
||||||
|
/// <summary>Queue of models the assistant wants downloaded (merged into gpu-rent models.yaml on next up/capture).</summary>
|
||||||
|
public partial class SwarmAssistentExtension
|
||||||
|
{
|
||||||
|
string WantedModelsPath() => Path.Combine(DataRoot(), ".gpu-rent-wanted-models.yaml");
|
||||||
|
|
||||||
|
string WantedCardsDir() => Path.Combine(DataRoot(), ".gpu-rent-wanted-cards");
|
||||||
|
|
||||||
|
public async Task<JObject> AssistentEnqueueWanted(Session session, string kind, string url, int version_id = 0, string title = null, JObject card = null)
|
||||||
|
{
|
||||||
|
await Task.CompletedTask;
|
||||||
|
kind = (kind ?? "lora").Trim().ToLowerInvariant();
|
||||||
|
if (kind is not ("lora" or "checkpoint" or "vae" or "embedding" or "controlnet" or "upscaler" or "clip"))
|
||||||
|
{
|
||||||
|
kind = "lora";
|
||||||
|
}
|
||||||
|
url = (url ?? "").Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(url) && version_id > 0)
|
||||||
|
{
|
||||||
|
url = $"https://civitai.red/models/0?modelVersionId={version_id}";
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "url or version_id required" };
|
||||||
|
}
|
||||||
|
if (version_id <= 0)
|
||||||
|
{
|
||||||
|
Match m = Regex.Match(url, @"modelVersionId=(\d+)", RegexOptions.IgnoreCase);
|
||||||
|
if (m.Success)
|
||||||
|
{
|
||||||
|
version_id = int.Parse(m.Groups[1].Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
string path = WantedModelsPath();
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path) ?? DataRoot());
|
||||||
|
Dictionary<string, List<WantedEntry>> sections = LoadWantedYaml(File.Exists(path) ? File.ReadAllText(path, Encoding.UTF8) : "");
|
||||||
|
|
||||||
|
if (version_id > 0)
|
||||||
|
{
|
||||||
|
foreach (List<WantedEntry> list in sections.Values)
|
||||||
|
{
|
||||||
|
if (list.Any(e => e.VersionId == version_id))
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path, ["version_id"] = version_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (List<WantedEntry> list in sections.Values)
|
||||||
|
{
|
||||||
|
if (list.Any(e => string.Equals(e.Url, url, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
return new JObject { ["success"] = true, ["already"] = true, ["path"] = path };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sections.TryGetValue(kind, out List<WantedEntry> bucket))
|
||||||
|
{
|
||||||
|
bucket = [];
|
||||||
|
sections[kind] = bucket;
|
||||||
|
}
|
||||||
|
bucket.Add(new WantedEntry { Url = url, Title = title, VersionId = version_id });
|
||||||
|
File.WriteAllText(path, WriteWantedYaml(sections), Encoding.UTF8);
|
||||||
|
|
||||||
|
if (card is not null && version_id > 0)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(WantedCardsDir());
|
||||||
|
string draft = Path.Combine(WantedCardsDir(), $"{version_id}.assistent.json");
|
||||||
|
File.WriteAllText(draft, card.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
|
}
|
||||||
|
return new JObject { ["success"] = true, ["path"] = path, ["version_id"] = version_id };
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class WantedEntry
|
||||||
|
{
|
||||||
|
public string Url;
|
||||||
|
public string Title;
|
||||||
|
public int VersionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Dictionary<string, List<WantedEntry>> LoadWantedYaml(string raw)
|
||||||
|
{
|
||||||
|
Dictionary<string, List<WantedEntry>> sections = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string currentKind = null;
|
||||||
|
WantedEntry cur = null;
|
||||||
|
void Flush()
|
||||||
|
{
|
||||||
|
if (cur is null || string.IsNullOrWhiteSpace(cur.Url) || string.IsNullOrWhiteSpace(currentKind))
|
||||||
|
{
|
||||||
|
cur = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sections.TryGetValue(currentKind, out List<WantedEntry> list))
|
||||||
|
{
|
||||||
|
list = [];
|
||||||
|
sections[currentKind] = list;
|
||||||
|
}
|
||||||
|
list.Add(cur);
|
||||||
|
cur = null;
|
||||||
|
}
|
||||||
|
foreach (string line in (raw ?? "").Split('\n'))
|
||||||
|
{
|
||||||
|
string t = line.TrimEnd();
|
||||||
|
if (string.IsNullOrWhiteSpace(t) || t.TrimStart().StartsWith('#'))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Match kindLine = Regex.Match(t, @"^([A-Za-z0-9_-]+):\s*$");
|
||||||
|
if (kindLine.Success && !t.TrimStart().StartsWith('-'))
|
||||||
|
{
|
||||||
|
Flush();
|
||||||
|
currentKind = kindLine.Groups[1].Value.Trim().ToLowerInvariant();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Match urlLine = Regex.Match(t, @"^\s*-\s*url:\s*[""']?(.+?)[""']?\s*$");
|
||||||
|
if (urlLine.Success)
|
||||||
|
{
|
||||||
|
Flush();
|
||||||
|
cur = new WantedEntry { Url = urlLine.Groups[1].Value.Trim() };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (cur is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Match titleLine = Regex.Match(t, @"^\s*title:\s*[""']?(.+?)[""']?\s*$");
|
||||||
|
if (titleLine.Success)
|
||||||
|
{
|
||||||
|
cur.Title = titleLine.Groups[1].Value.Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Match vidLine = Regex.Match(t, @"^\s*version_id:\s*(\d+)\s*$");
|
||||||
|
if (vidLine.Success && int.TryParse(vidLine.Groups[1].Value, out int vid))
|
||||||
|
{
|
||||||
|
cur.VersionId = vid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Flush();
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
static string WriteWantedYaml(Dictionary<string, List<WantedEntry>> sections)
|
||||||
|
{
|
||||||
|
StringBuilder sb = new();
|
||||||
|
sb.AppendLine("# Assistent wanted queue — merged into local models.yaml on gpu-rent up/capture");
|
||||||
|
string[] order = ["checkpoint", "lora", "vae", "embedding", "controlnet", "upscaler", "clip"];
|
||||||
|
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (string kind in order.Concat(sections.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
if (!seen.Add(kind) || !sections.TryGetValue(kind, out List<WantedEntry> list) || list.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
sb.AppendLine($"{kind}:");
|
||||||
|
foreach (WantedEntry e in list)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" - url: \"{e.Url.Replace("\"", "%22")}\"");
|
||||||
|
if (!string.IsNullOrWhiteSpace(e.Title))
|
||||||
|
{
|
||||||
|
sb.AppendLine($" title: \"{e.Title.Replace("\"", "'")}\"");
|
||||||
|
}
|
||||||
|
if (e.VersionId > 0)
|
||||||
|
{
|
||||||
|
sb.AppendLine($" version_id: {e.VersionId}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ When instructions conflict, apply this order (highest wins):
|
|||||||
3. **Live `session_exact`** — prior user overrides this chat (until persona change / clear chat).
|
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.
|
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.
|
5. **Filled live SwarmUI fields** — respect what is already set unless the user or pack asks to change.
|
||||||
6. **`memory_hits` (vector RAG)** — notes, pitfalls, LoRA blurbs. Never override exact numbers or the user’s param request.
|
6. **`memory_hits` (vector RAG)** — notes, pitfalls, LoRA blurbs. Shared hits apply to every persona; personal hits are this persona only and overwrite shared on the same kind+key. Never override exact numbers or the user’s param request.
|
||||||
7. Guesses — last resort only.
|
7. 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 = 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.
|
||||||
@@ -25,7 +25,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
|
- Use only LoRAs listed in `available_loras` / `enabled_loras` (exact `name`), or Civitai search candidates.
|
||||||
- Prefer listed `trigger_phrase` / `triggers` — **never invent** trigger words.
|
- 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).
|
- `session_exact` / `recommended_params` — session overrides and defaults (Exact KV is in the system block above).
|
||||||
- `memory_hits` are retrieved notes (LoRA tips, pitfalls). Trust them over guesses, but **not** over Exact or the user.
|
- `memory_hits` are retrieved notes (LoRA tips, pitfalls). 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.
|
- `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`.
|
- `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.
|
- `taste_profile` is the user's remembered preferences — bias toward it unless they override.
|
||||||
@@ -77,7 +77,7 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
"pack": null,
|
"pack": null,
|
||||||
"actions": ["generate"],
|
"actions": ["generate"],
|
||||||
"search_query": null,
|
"search_query": null,
|
||||||
"memories": [{"kind": "lora", "key": "name", "text": "fact"}],
|
"memories": [{"kind": "lora", "key": "name", "text": "fact", "scope": "personal"}],
|
||||||
"notes": "one-line why"
|
"notes": "one-line why"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -91,13 +91,13 @@ A JSON block named "Live SwarmUI context" is attached. Treat it as ground truth
|
|||||||
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
|
- `vary: true` — new random seed. `lock_seed: true` — reuse current seed.
|
||||||
- `pack` — switch active prompt pack for a follow-up hop.
|
- `pack` — switch active prompt pack for a follow-up hop.
|
||||||
- Do not invent model or LoRA filenames.
|
- Do not invent model or LoRA filenames.
|
||||||
- Memory: `actions` may include `memory_upsert` or `memory_forget` with `memories: [{kind,key,text}]`.
|
- Memory: `actions` may include `memory_upsert` or `memory_forget` with `memories: [{kind,key,text,scope}]`. Default scope is personal (this persona). `"scope":"shared"` is visible to all personas; personal never copies into shared.
|
||||||
|
|
||||||
### Actions (auto-safe)
|
### Actions (auto-safe)
|
||||||
|
|
||||||
- `"generate"` — after Apply, start generation. When the user explicitly asks to generate, always include this; the UI applies silently (no Apply-button strip).
|
- `"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.
|
- `"search_civitai"` — Civitai search; user Confirms downloads.
|
||||||
- `"interrupt"` — stop generation.
|
- `"interrupt"` — stop generation.
|
||||||
- `"memory_upsert"` / `"memory_forget"` — write or delete facts in vector memory.
|
- `"memory_upsert"` / `"memory_forget"` — write or delete vector memory (personal by default; `scope: "shared"` for the common store).
|
||||||
- `look_at: ["generate", "ref1"]` — vision hop.
|
- `look_at: ["generate", "ref1"]` — vision hop.
|
||||||
- Pure Q&A with no change: omit the JSON patch.
|
- Pure Q&A with no change: omit the JSON patch.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
You have two memory layers:
|
You have two memory layers:
|
||||||
|
|
||||||
1. **Exact memory** (`## Exact memory` + live `exact` / `session_exact`) — canonical KV defaults (generation params, aspect table, architecture facts). Always prefer Exact over RAG for numbers and defaults.
|
1. **Exact memory** (`## Exact memory` + live `exact` / `session_exact`) — canonical KV defaults (generation params, aspect table, architecture facts). Always prefer Exact over RAG for numbers and defaults.
|
||||||
2. **Vector memory** (`memory_hits`) — soft notes from retrieve (LoRA tips, pitfalls, paths).
|
2. **Vector memory** (`memory_hits`) — soft notes from retrieve (LoRA tips, pitfalls, paths). Hits are **shared + this persona**. `scope: "personal"` overwrites `scope: "shared"` on the same `kind`+`key`. Other personas never see your personal rows.
|
||||||
|
|
||||||
## Priority
|
## Priority
|
||||||
|
|
||||||
@@ -13,7 +13,9 @@ User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hit
|
|||||||
|
|
||||||
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
|
- Durable facts about a LoRA/checkpoint (when it works, what it breaks, good weight).
|
||||||
- Bad paths / pitfalls you discovered this session.
|
- Bad paths / pitfalls you discovered this session.
|
||||||
- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…" }]`.
|
- Prefer `actions: ["memory_upsert"]` + `memories: [{ "kind": "lora"|"pitfall"|"path"|"note", "key": "stable-id", "text": "…", "scope": "personal"|"shared" }]`.
|
||||||
|
- Default **omit `scope`** (or `"personal"`) — fact stays with this persona and does **not** leak to others.
|
||||||
|
- Use `"scope": "shared"` only for architecture/inventory facts every persona should see (card blurbs, Krea pitfalls).
|
||||||
|
|
||||||
## When not to write
|
## When not to write
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ User (this turn) > `session_exact` > Exact KV > filled live fields > `memory_hit
|
|||||||
- Do not dump the full inventory — retrieve already surfaces relevant blurbs.
|
- Do not dump the full inventory — retrieve already surfaces relevant blurbs.
|
||||||
- Do not store the user's taste profile (that is `taste_profile` / taste.json).
|
- Do not store the user's taste profile (that is `taste_profile` / taste.json).
|
||||||
- Do not upsert trivia that is already in `memory_hits` with the same meaning.
|
- Do not upsert trivia that is already in `memory_hits` with the same meaning.
|
||||||
- `memory_forget` only when a fact is wrong or obsolete.
|
- `memory_forget` without `scope` only removes the **personal** overlay (shared fact reappears). Use `"scope": "shared"` to delete a shared row.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"kind": "note",
|
||||||
|
"key": "cinema_framing",
|
||||||
|
"tags": ["cinema", "framing"],
|
||||||
|
"text": "Cinema persona: prefer establishing wides, motivated lamp practicals, and 2.39/16:9 cinematic framing. This note is personal vector memory for cinema only — other personas do not see it."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
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/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
|
||||||
|
|
||||||
**Version 0.7.6** — Bugfix: clear/new abort in-flight chat; Send blocked while generating; Interrupt always drops stream bubble; patch-clear resets lastPatch.
|
**Version 0.8.1** — Chats live on disk (`Assistent/chats/`), the chat model is **parked out of VRAM** before every Generate, memory + wanted queue are editable in ⚙, Ollama health sits in the chat header. Vector memory is shared + personal: personal never leaks into shared; shared is visible to every persona; personal overwrites the same kind+key.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`)
|
- **Left — Board tabs:** **Generate** (full-height live view) | **Refs** (reference grid + badge `N · vision M`); **Посмотри результат** attaches the finished frame and asks for a verdict
|
||||||
- **Splitter:** drag to resize panes
|
- **Splitter:** drag to resize panes
|
||||||
- **Right:** Chat | Cards; persona / pack / Ollama chat model; settings gear (memory model + skills)
|
- **Right:** Chat | Cards; persona / pack / Ollama chat model; **Ollama health** badge; settings gear (memory model + skills + **Память**)
|
||||||
- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
|
- **Chips / slash:** loaded from `Config/_base/ui.json` (persona can override)
|
||||||
|
|
||||||
## Config (bundled + overlay)
|
## Config (bundled + overlay)
|
||||||
@@ -16,12 +16,23 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
|
|||||||
```
|
```
|
||||||
Config/
|
Config/
|
||||||
_base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
|
_base/ # defaults (assistant, ui, models/krea2, exact.json, core, packs, skills, memory-seed, identity)
|
||||||
personas/<id>/ # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / overrides
|
personas/<id>/ # sparse preset: persona/voice/likes/dislikes/rules + optional exact.json / memory-seed / overrides
|
||||||
```
|
```
|
||||||
|
|
||||||
Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same layout, plus `settings.json`, `taste.json`, `personas.json` (legacy prompt overlay), `ollama-roles.json`, `memory/assistent.sqlite`.
|
Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder layout as `Config/`, i.e. drop `_base/…` and `personas/<id>/…` files to override any bundled preset. Plus this extension's own state:
|
||||||
|
|
||||||
Copy `personas/cinema/` → `noir/`, edit only differing JSON files.
|
```
|
||||||
|
Assistent/
|
||||||
|
_base/ personas/<id>/ # overlay presets — same names as Config/, sparse
|
||||||
|
settings.json # embed_model, base_url, per-persona skills
|
||||||
|
ui-state.json # pack / persona / auto_* / pane_width / models — seeds a fresh browser
|
||||||
|
taste.json # learned taste profile (wins over localStorage)
|
||||||
|
chats/<id>.json # chat history, newest 60 kept
|
||||||
|
ollama-roles.json # chat vs memory model tags
|
||||||
|
memory/assistent.sqlite # vector store (shared + per-persona)
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy `personas/cinema/` → `noir/`, edit only differing JSON files. Persona prompt overrides belong in `personas/<id>/` — the old flat `personas.json` is legacy and only read when no overlay folder exists for that id.
|
||||||
|
|
||||||
## Exact memory (KV)
|
## Exact memory (KV)
|
||||||
|
|
||||||
@@ -33,18 +44,34 @@ Copy `personas/cinema/` → `noir/`, edit only differing JSON files.
|
|||||||
|
|
||||||
## Vector memory
|
## 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/<id>/memory-seed/` and chat upserts (default). Never copied into shared. Other personas do not retrieve it.
|
||||||
|
- Retrieve = shared ∪ this persona (and `extends` parents). Same `kind`+`key`: personal overwrites parent overwrites shared. Forget without `scope` only drops the personal overlay.
|
||||||
- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
|
- SQLite + Ollama `/api/embed` (default `nomic-embed-text`, pick in ⚙)
|
||||||
- First chat seeds `Config/_base/memory-seed/` (pointers + pitfalls; numbers live in Exact)
|
|
||||||
- Agents upsert via patch `memory_upsert` / `memory_forget`
|
|
||||||
- Cards ingest on save; retrieve → `memory_hits` in live context (inventory slimmed)
|
|
||||||
- Soft notes only — Exact and the user beat RAG for params
|
- 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
|
||||||
|
|
||||||
|
## Chats on disk
|
||||||
|
|
||||||
|
- Every chat is written to `Assistent/chats/<id>.json` (messages + a Generate params snapshot), so History survives a cleared browser and follows the data volume across VMs
|
||||||
|
- localStorage stays as a fast cache; on first run with an empty `chats/` the old `swarm_assistent_chats_v1` store is migrated up once
|
||||||
|
- `ui-state.json` seeds a **fresh** browser only — anything already in localStorage wins, and `auto_download` is never restored as on
|
||||||
|
|
||||||
|
## VRAM handover
|
||||||
|
|
||||||
|
- Before every Generate the chat model is unloaded (`keep_alive: 0`) so Krea 2 gets the whole GPU
|
||||||
|
- Back in the Chat tab it is warmed again with a 1-token request (`keep_alive 15m`, `num_ctx` from `assistant.json`)
|
||||||
|
- Embed / memory models are never parked — reloading them would stall every retrieve
|
||||||
|
|
||||||
## UX
|
## UX
|
||||||
|
|
||||||
- **Send to Assistent** under Generate/History → Ref + Assistent tab
|
- **Send to Assistent** under Generate/History → Ref + Assistent tab
|
||||||
- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
|
- Enter sends; Shift+Enter newline; Interrupt cancels chat epoch
|
||||||
- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
|
- Manual **Apply + Generate** / `/gen` always generate; Auto-generate checkbox only for LLM auto-path
|
||||||
- Civitai Confirm required (unless auto-download)
|
- **Посмотри результат** / auto-critique wait for a real Generate frame — model previews and unfinished batches are skipped
|
||||||
|
- Civitai Confirm required (unless auto-download); queued-but-missing models show a `⏳ wanted` badge in Cards
|
||||||
|
|
||||||
### Slash commands (client-side, no LLM)
|
### Slash commands (client-side, no LLM)
|
||||||
|
|
||||||
@@ -103,10 +130,14 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
|
|||||||
| `AssistentGetPacks` | Prompt pack texts |
|
| `AssistentGetPacks` | Prompt pack texts |
|
||||||
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
|
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
|
||||||
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
|
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
|
||||||
| `AssistentEnqueueWanted` | Wanted YAML queue |
|
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
|
||||||
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
|
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
|
||||||
| `AssistentSearchCivitai` | Civitai LoRA search |
|
| `AssistentSearchCivitai` | Civitai LoRA search |
|
||||||
| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) |
|
| `AssistentChat` / `AssistentChatWS` | Chat (+ memory retrieve + Civitai hop) |
|
||||||
|
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
|
||||||
|
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/<id>.json` |
|
||||||
|
| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` |
|
||||||
|
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+53
-1767
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@
|
|||||||
<div class="sa-board" id="sa_board" tabindex="0" title="Перетащи изображение на окно или вставь (Ctrl+V). Кликни, чтобы выбрать."></div>
|
<div class="sa-board" id="sa_board" tabindex="0" title="Перетащи изображение на окно или вставь (Ctrl+V). Кликни, чтобы выбрать."></div>
|
||||||
<div class="sa-live-params" id="sa_live_params" title="Текущие параметры Generate">—</div>
|
<div class="sa-live-params" id="sa_live_params" title="Текущие параметры Generate">—</div>
|
||||||
<div class="sa-image-actions" id="sa_image_actions">
|
<div class="sa-image-actions" id="sa_image_actions">
|
||||||
|
<button type="button" class="basic-button sa-primary" id="sa_btn_look_result" title="Прикрепить готовый кадр Generate и спросить мнение">Посмотри результат</button>
|
||||||
<button type="button" class="basic-button" id="sa_btn_use_current" title="Скопировать текущий Generate в Ref">Снимок gen</button>
|
<button type="button" class="basic-button" id="sa_btn_use_current" title="Скопировать текущий Generate в Ref">Снимок gen</button>
|
||||||
<button type="button" class="basic-button" id="sa_btn_clear_image" title="Очистить выбранный Ref">Очистить слот</button>
|
<button type="button" class="basic-button" id="sa_btn_clear_image" title="Очистить выбранный Ref">Очистить слот</button>
|
||||||
<div class="sa-more-wrap" id="sa_board_more_wrap">
|
<div class="sa-more-wrap" id="sa_board_more_wrap">
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
<div class="sa-chat-title">
|
<div class="sa-chat-title">
|
||||||
Assistent
|
Assistent
|
||||||
<span class="sa-live-dot" id="sa_live_dot" hidden></span>
|
<span class="sa-live-dot" id="sa_live_dot" hidden></span>
|
||||||
|
<span class="sa-health" id="sa_ollama_health" role="button" tabindex="0" title="Ollama: проверяю…" hidden>Ollama · …</span>
|
||||||
<div class="sa-sessions-bar">
|
<div class="sa-sessions-bar">
|
||||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
|
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
|
||||||
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
|
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
|
||||||
@@ -76,6 +78,18 @@
|
|||||||
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
|
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
|
||||||
<div class="sa-skills-label">Скилы (процедуры)</div>
|
<div class="sa-skills-label">Скилы (процедуры)</div>
|
||||||
<div class="sa-skills-box" id="sa_skills_box"></div>
|
<div class="sa-skills-box" id="sa_skills_box"></div>
|
||||||
|
<div class="sa-mem-head">
|
||||||
|
<span class="sa-skills-label">Память</span>
|
||||||
|
<select id="sa_mem_kind" class="sa-select sa-mem-kind" title="Фильтр по типу">
|
||||||
|
<option value="all">Все типы</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_mem_refresh" title="Перечитать память и очередь wanted">⟳</button>
|
||||||
|
</div>
|
||||||
|
<div class="sa-mem-list" id="sa_mem_list"></div>
|
||||||
|
<div class="sa-mem-foot">
|
||||||
|
<span class="sa-mem-total" id="sa_mem_total">Всего: —</span>
|
||||||
|
<span class="sa-mem-wanted" id="sa_mem_wanted" title="Модели в очереди на следующий gpu-rent up">Очередь wanted: —</span>
|
||||||
|
</div>
|
||||||
<label class="sa-check"><input type="checkbox" id="sa_auto_vision" /> Авто-прикреплять Generate к чату</label>
|
<label class="sa-check"><input type="checkbox" id="sa_auto_vision" /> Авто-прикреплять Generate к чату</label>
|
||||||
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
|
<label class="sa-check"><input type="checkbox" id="sa_auto_apply" checked /> Авто-применять патч</label>
|
||||||
<label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
|
<label class="sa-check"><input type="checkbox" id="sa_auto_generate" checked /> Авто-Generate после патча</label>
|
||||||
@@ -96,20 +110,7 @@
|
|||||||
<span class="sa-elapsed" id="sa_elapsed"></span>
|
<span class="sa-elapsed" id="sa_elapsed"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="sa-composer" id="sa_composer">
|
<div class="sa-composer" id="sa_composer">
|
||||||
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры">
|
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
|
||||||
<button type="button" class="sa-chip" data-aspect="1:1" title="1024×1024">1:1</button>
|
|
||||||
<button type="button" class="sa-chip" data-aspect="4:5" title="928×1152">4:5</button>
|
|
||||||
<button type="button" class="sa-chip" data-aspect="2:3" title="832×1248">2:3</button>
|
|
||||||
<button type="button" class="sa-chip" data-aspect="16:9" title="1376×768">16:9</button>
|
|
||||||
<button type="button" class="sa-chip" data-aspect="9:16" title="768×1376">9:16</button>
|
|
||||||
<span class="sa-chip-sep" aria-hidden="true"></span>
|
|
||||||
<button type="button" class="sa-chip" data-seed="lock" title="Оставить текущий seed">Seed lock</button>
|
|
||||||
<button type="button" class="sa-chip" data-seed="random" title="Случайный seed">Seed −1</button>
|
|
||||||
<button type="button" class="sa-chip" data-vary="1" title="Тот же промпт, новый seed + generate">Vary</button>
|
|
||||||
<span class="sa-chip-sep" aria-hidden="true"></span>
|
|
||||||
<button type="button" class="sa-chip" data-krea-profile="turbo" title="Turbo: steps 8, CFG 1">Turbo</button>
|
|
||||||
<button type="button" class="sa-chip" data-krea-profile="raw" title="RAW: steps 28, CFG 4.5">RAW</button>
|
|
||||||
</div>
|
|
||||||
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
|
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
|
||||||
<div class="sa-slash-wrap">
|
<div class="sa-slash-wrap">
|
||||||
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды"></textarea>
|
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды"></textarea>
|
||||||
|
|||||||
Reference in New Issue
Block a user