From 1a03c3178f5f01433080ba766b243d0806d22700 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 22 Aug 2026 14:27:59 +0300 Subject: [PATCH] Ship Assistent 0.12.1: training tab, dataset pipeline, and heard RAG. Restructure UI with app-level tabs and chat history drawer; add dataset curation, HF import, Modelfile/QLoRA hooks, and link approved samples to the agent immediately via heard vector memory without waiting for fine-tuning. Co-authored-by: Cursor --- Assets/assistent.bundle.js | 1503 +++++++++++++++++++-------- Assets/assistent.css | 485 ++++++++- AssistentChatPipeline.cs | 97 +- AssistentConfig.cs | 34 + AssistentHuggingFace.cs | 483 +++++++++ AssistentMemory.Heard.cs | 189 ++++ AssistentMemory.Training.cs | 345 ++++++ AssistentMemory.cs | 15 +- AssistentPatch.cs | 6 + AssistentPersist.cs | 2 +- AssistentTraining.Agent.cs | 174 ++++ AssistentTraining.cs | 443 ++++++++ AssistentTrainingJobs.cs | 417 ++++++++ README.md | 12 + SwarmAssistentExtension.cs | 28 +- Tabs/Text2Image/Assistent.html | 428 +++++--- docs/reviews/2026-08-22-review-1.md | 127 +++ scripts/train_qlora.py | 88 ++ src/app.js | 147 ++- src/main.js | 3 + src/training.js | 531 ++++++++++ 21 files changed, 4919 insertions(+), 638 deletions(-) create mode 100644 AssistentHuggingFace.cs create mode 100644 AssistentMemory.Heard.cs create mode 100644 AssistentMemory.Training.cs create mode 100644 AssistentTraining.Agent.cs create mode 100644 AssistentTraining.cs create mode 100644 AssistentTrainingJobs.cs create mode 100644 docs/reviews/2026-08-22-review-1.md create mode 100644 scripts/train_qlora.py create mode 100644 src/training.js diff --git a/Assets/assistent.bundle.js b/Assets/assistent.bundle.js index 1c869ab..0b966de 100644 --- a/Assets/assistent.bundle.js +++ b/Assets/assistent.bundle.js @@ -351,6 +351,7 @@ const LS_WELCOMED = "swarm_assistent_welcomed"; const LS_CHATS2 = "swarm_assistent_chats_v1"; const LS_BOARD_TAB = "swarm_assistent_board_tab"; + const LS_CHATS_DRAWER = "swarm_assistent_chats_drawer"; const MAX_CHATS = 40; const MAX_CHAT_MSGS = 24; const TAB_BUTTON_ID = "maintab_assistent"; @@ -492,6 +493,7 @@ activeChatId: null, restoringChat: false, chatsPanelOpen: false, + chatsDrawerOpen: false, chatsQuery: "", chatsSearchHits: null, slashIndex: 0, @@ -503,7 +505,8 @@ settingsPersonaId: null, wanted: { count: 0, items: [] }, wantedKeys: /* @__PURE__ */ new Set(), - ollamaHealth: "unknown" + ollamaHealth: "unknown", + trainingLock: false }; const HOP_BUDGET = 4; function isContinuationTurn(opts) { @@ -532,7 +535,7 @@ function diskPersist() { return window.SA && window.SA.persist || null; } - function $(id) { + function $2(id) { return document.getElementById(id); } function modelShort(name) { @@ -548,21 +551,21 @@ return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } function hideChatEmpty() { - const empty = $("sa_chat_empty"); + const empty = $2("sa_chat_empty"); if (empty) { empty.hidden = true; } } let scrollMessagesRaf = 0; function messagesNearBottom(thresholdPx = 96) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return true; } return box.scrollHeight - box.scrollTop - box.clientHeight <= thresholdPx; } function scrollMessagesToBottom({ force = false } = {}) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return; } @@ -574,15 +577,15 @@ } scrollMessagesRaf = requestAnimationFrame(() => { scrollMessagesRaf = 0; - const el = $("sa_messages"); + const el = $2("sa_messages"); if (el && (force || messagesNearBottom(120))) { el.scrollTop = el.scrollHeight; } }); } function showChatEmptyIfIdle() { - const box = $("sa_messages"); - const empty = $("sa_chat_empty"); + const box = $2("sa_messages"); + const empty = $2("sa_chat_empty"); if (!box || !empty) { return; } @@ -603,7 +606,7 @@ if (!state.gotDelta && (state.busyPhase === "thinking" || state.busyPhase === "waiting") && elapsed > 1600) { state.busyPhase = state.llmParked || state.expectColdLoad ? "loading" : "waiting"; } - const model = modelShort($("sa_model")?.value); + const model = modelShort($2("sa_model")?.value); const labels = { encoding: "Encoding image\u2026", waiting: "\u0416\u0434\u0443 Ollama / \u043F\u0435\u0440\u0432\u044B\u0439 \u0442\u043E\u043A\u0435\u043D\u2026", @@ -618,15 +621,15 @@ refining: "Civitai search done \u2014 refining\u2026" }; const text = labels[state.busyPhase] || "Working\u2026"; - const barText = $("sa_livebar_text"); + const barText = $2("sa_livebar_text"); if (barText) { barText.textContent = text; } - const elapsedEl = $("sa_elapsed"); + const elapsedEl = $2("sa_elapsed"); if (elapsedEl) { elapsedEl.textContent = fmtElapsed(elapsed); } - const status = $("sa_status"); + const status = $2("sa_status"); if (status) { status.textContent = text; status.classList.add("sa-status-busy"); @@ -636,21 +639,21 @@ state.busyStarted = Date.now(); state.gotDelta = false; state.busyPhase = phase || "thinking"; - $("swarm_assistent_root")?.classList.add("sa-is-busy"); - $("sa_composer")?.classList.add("sa-composer-busy"); - const send = $("sa_btn_send"); + $2("swarm_assistent_root")?.classList.add("sa-is-busy"); + $2("sa_composer")?.classList.add("sa-composer-busy"); + const send = $2("sa_btn_send"); if (send) { send.disabled = true; } - const input = $("sa_input"); + const input = $2("sa_input"); if (input) { input.classList.add("sa-input-busy"); } - const bar = $("sa_livebar"); + const bar = $2("sa_livebar"); if (bar) { bar.hidden = false; } - const dot = $("sa_live_dot"); + const dot = $2("sa_live_dot"); if (dot) { dot.hidden = false; } @@ -669,25 +672,25 @@ } const elapsed = Date.now() - (state.busyStarted || Date.now()); state.busyPhase = "idle"; - $("swarm_assistent_root")?.classList.remove("sa-is-busy"); - $("sa_composer")?.classList.remove("sa-composer-busy"); - const send = $("sa_btn_send"); + $2("swarm_assistent_root")?.classList.remove("sa-is-busy"); + $2("sa_composer")?.classList.remove("sa-composer-busy"); + const send = $2("sa_btn_send"); if (send) { send.disabled = false; } - const input = $("sa_input"); + const input = $2("sa_input"); if (input) { input.classList.remove("sa-input-busy"); } - const bar = $("sa_livebar"); + const bar = $2("sa_livebar"); if (bar) { bar.hidden = true; } - const dot = $("sa_live_dot"); + const dot = $2("sa_live_dot"); if (dot) { dot.hidden = true; } - const status = $("sa_status"); + const status = $2("sa_status"); if (status) { status.classList.remove("sa-status-busy"); } @@ -699,13 +702,13 @@ syncGenerateBusy(); } function setStatus(text) { - const el = $("sa_status"); + const el = $2("sa_status"); if (el) { el.textContent = text || ""; } } function setInterruptVisible(on) { - const btn = $("sa_btn_interrupt"); + const btn = $2("sa_btn_interrupt"); if (btn) { btn.hidden = !on; btn.classList.toggle("sa-interrupt-active", !!on); @@ -785,8 +788,8 @@ } function updateGate() { const ok = isKreaSelected(); - const gate = $("sa_gate"); - const layout = $("sa_layout"); + const gate = $2("sa_gate"); + const layout = $2("sa_layout"); if (gate) { gate.hidden = ok; if (!ok) { @@ -794,7 +797,7 @@ const seen = [m.architecture, m.compat_class, m.name].filter(Boolean).join(" \xB7 "); const p = gate.querySelector("p"); if (p) { - p.innerHTML = seen ? `Swarm Assistent is for Krea 2 models only. Current: ${escapeHtml( + p.innerHTML = seen ? `Swarm Assistent is for Krea 2 models only. Current: ${escapeHtml2( seen )} \u2014 pick a checkpoint with architecture krea-2.` : "Swarm Assistent is for Krea 2 models only. Select a Krea 2 checkpoint on Generate to enable the chat."; } @@ -805,7 +808,7 @@ } return ok; } - function escapeHtml(s) { + function escapeHtml2(s) { return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } const PROSE_SECTION_TITLES = { @@ -874,7 +877,7 @@ return; } parts.push( - `
    ${listItems.map((li) => `
  • ${formatProseInline(escapeHtml(li))}
  • `).join("")}
` + `
    ${listItems.map((li) => `
  • ${formatProseInline(escapeHtml2(li))}
  • `).join("")}
` ); listItems = []; }; @@ -888,7 +891,7 @@ } const level = Math.min((line.match(/^#+/) || ["###"])[0].length, 3); parts.push( - `
${escapeHtml(title)}
` + `
${escapeHtml2(title)}
` ); continue; } @@ -902,7 +905,7 @@ parts.push(''); continue; } - parts.push(`

${formatProseInline(escapeHtml(line))}

`); + parts.push(`

${formatProseInline(escapeHtml2(line))}

`); } flushList(); return parts.join(""); @@ -1352,13 +1355,13 @@ ${patch.prompt}`; generate = false; } else if (commanded) { generate = true; - } else if (packBlocksAutoGenerate($("sa_pack")?.value || "")) { + } else if (packBlocksAutoGenerate($2("sa_pack")?.value || "")) { generate = false; } else { generate = modelAsked || implied; } const hasLook = !!patch && (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null); - const honorLook = opts.fromAutoCritique || opts.fromVisionHop || !machine && userAsksLook(userText) || packWantsVision($("sa_pack")?.value); + const honorLook = opts.fromAutoCritique || opts.fromVisionHop || !machine && userAsksLook(userText) || packWantsVision($2("sa_pack")?.value); const look = !!(hasLook && !vetoed && !generate && honorLook); return { generate, look, vetoed }; } @@ -1401,7 +1404,7 @@ ${patch.prompt}`; } } function syncBuildGenButton() { - const btn = $("sa_btn_build_gen"); + const btn = $2("sa_btn_build_gen"); if (!btn) { return; } @@ -1415,11 +1418,11 @@ ${patch.prompt}`; } } function defaultPackId() { - return state.config?.assistant?.default_pack || $("sa_pack")?.querySelector("option")?.value || "ordinary"; + return state.config?.assistant?.default_pack || $2("sa_pack")?.querySelector("option")?.value || "ordinary"; } function syncModeBadge() { - const badge = $("sa_mode_badge"); - const pack = $("sa_pack")?.value || defaultPackId(); + const badge = $2("sa_mode_badge"); + const pack = $2("sa_pack")?.value || defaultPackId(); if (!badge) { return; } @@ -1441,7 +1444,7 @@ ${patch.prompt}`; badge.classList.toggle("sa-mode-hot", pack === "critique_image" || pack === "inpaint_edit"); } function syncLiveParamsBar() { - const el = $("sa_live_params"); + const el = $2("sa_live_params"); if (!el) { return; } @@ -1604,7 +1607,7 @@ ${patch.prompt}`; const tab = document.getElementById(TAB_BUTTON_ID); if (tab) { tab.click(); - setTimeout(() => $("sa_input")?.focus(), 50); + setTimeout(() => $2("sa_input")?.focus(), 50); return true; } const pane = document.getElementById("assistent"); @@ -1614,7 +1617,7 @@ ${patch.prompt}`; } catch (e) { } } - setTimeout(() => $("sa_input")?.focus(), 50); + setTimeout(() => $2("sa_input")?.focus(), 50); return !!tab; } function historyMessageLimit() { @@ -2034,7 +2037,7 @@ ${patch.prompt}`; await runGenerateFromPatch({ actions: ["generate"] }, { force: true }); } function renderBoard() { - const board = $("sa_board"); + const board = $2("sa_board"); if (!board) { return; } @@ -2142,11 +2145,11 @@ ${patch.prompt}`; return el; } function ensureGenLightbox() { - let root = $("sa_gen_lightbox"); + let root = $2("sa_gen_lightbox"); if (root) { return root; } - const host = $("swarm_assistent_root") || document.body; + const host = $2("swarm_assistent_root") || document.body; root = document.createElement("div"); root.id = "sa_gen_lightbox"; root.className = "sa-lightbox"; @@ -2220,9 +2223,9 @@ ${patch.prompt}`; return; } root.hidden = false; - const img = $("sa_lb_img"); - const title = $("sa_lb_title"); - const idx = $("sa_lb_idx"); + const img = $2("sa_lb_img"); + const title = $2("sa_lb_title"); + const idx = $2("sa_lb_idx"); if (img) { img.src = row.src; img.alt = row.label || row.id; @@ -2250,7 +2253,7 @@ ${patch.prompt}`; } function closeGenLightbox() { state.lightboxIndex = -1; - const root = $("sa_gen_lightbox"); + const root = $2("sa_gen_lightbox"); if (root) { root.hidden = true; } @@ -2269,23 +2272,23 @@ ${patch.prompt}`; } function syncBoardChrome() { const tab = state.boardTab === "refs" ? "refs" : "generate"; - $("sa_board_tab_gen")?.classList.toggle("sa-board-tab-active", tab === "generate"); - $("sa_board_tab_refs")?.classList.toggle("sa-board-tab-active", tab === "refs"); - $("sa_board_tab_gen")?.setAttribute("aria-selected", tab === "generate" ? "true" : "false"); - $("sa_board_tab_refs")?.setAttribute("aria-selected", tab === "refs" ? "true" : "false"); - const addBtn = $("sa_btn_add_ref"); + $2("sa_board_tab_gen")?.classList.toggle("sa-board-tab-active", tab === "generate"); + $2("sa_board_tab_refs")?.classList.toggle("sa-board-tab-active", tab === "refs"); + $2("sa_board_tab_gen")?.setAttribute("aria-selected", tab === "generate" ? "true" : "false"); + $2("sa_board_tab_refs")?.setAttribute("aria-selected", tab === "refs" ? "true" : "false"); + const addBtn = $2("sa_btn_add_ref"); if (addBtn) { addBtn.hidden = tab !== "refs"; } - const maskBtn = $("sa_btn_as_mask"); - const clearSlotBtn = $("sa_btn_clear_image"); + const maskBtn = $2("sa_btn_as_mask"); + const clearSlotBtn = $2("sa_btn_clear_image"); if (maskBtn) { maskBtn.hidden = tab !== "refs"; } if (clearSlotBtn) { clearSlotBtn.hidden = tab !== "refs"; } - const badge = $("sa_refs_badge"); + const badge = $2("sa_refs_badge"); if (badge) { const refs = refSlots(); const withImg = refs.filter((s) => s.src).length; @@ -2297,9 +2300,9 @@ ${patch.prompt}`; badge.hidden = true; } } - let genBadge = $("sa_gen_badge"); + let genBadge = $2("sa_gen_badge"); if (!genBadge) { - const genTab = $("sa_board_tab_gen"); + const genTab = $2("sa_board_tab_gen"); if (genTab) { genBadge = document.createElement("span"); genBadge.id = "sa_gen_badge"; @@ -2455,11 +2458,11 @@ ${patch.prompt}`; if (localStorage.getItem(LS_WELCOMED) === "1") { return; } - if (!$("sa_messages")) { + if (!$2("sa_messages")) { return; } localStorage.setItem(LS_WELCOMED, "1"); - const box = $("sa_messages"); + const box = $2("sa_messages"); hideChatEmpty(); const div = document.createElement("div"); div.className = "sa-msg assistant sa-welcome"; @@ -2528,8 +2531,8 @@ ${patch.prompt}`; scheduler: val("input_scheduler") || null, batch: parseInt(val("input_images") || val("input_batchsize") || "0", 10) || null, loras, - persona: $("sa_persona")?.value || "neutral", - pack: $("sa_pack")?.value || defaultPackId(), + persona: $2("sa_persona")?.value || "neutral", + pack: $2("sa_pack")?.value || defaultPackId(), sessionExact, lastPatch, genResults: Array.isArray(state.genResults) ? state.genResults.map((r) => ({ @@ -2640,7 +2643,7 @@ ${patch.prompt}`; function applyPersonaForChat(personaId, { quiet = false } = {}) { const id = String(personaId || "neutral").trim() || "neutral"; return new Promise((resolve) => { - const sel = $("sa_persona"); + const sel = $2("sa_persona"); if (sel && [...sel.options].some((o) => o.value === id)) { sel.value = id; } @@ -2654,7 +2657,7 @@ ${patch.prompt}`; resolve(); return; } - const packKeep = $("sa_pack")?.value; + const packKeep = $2("sa_pack")?.value; genericRequest( "AssistentGetConfig", { persona: id }, @@ -2789,7 +2792,7 @@ ${patch.prompt}`; persistChatsStore(); } function resetMessagesUi(emptyHint) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return; } @@ -2801,7 +2804,7 @@ ${patch.prompt}`; box.appendChild(empty); } function renderHistoryIntoUi(messages) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return; } @@ -2824,7 +2827,7 @@ ${patch.prompt}`; } } function updateSessionLabel() { - const el = $("sa_session_label"); + const el = $2("sa_session_label"); if (!el) { return; } @@ -2836,7 +2839,7 @@ ${patch.prompt}`; return (state.chats || []).filter((c) => (c.messages || []).length > 0).length; } function syncHistoryBadge() { - const btn = $("sa_btn_chats"); + const btn = $2("sa_btn_chats"); if (!btn) { return; } @@ -2871,7 +2874,7 @@ ${patch.prompt}`; return false; } function renderChatsList() { - const root = $("sa_chats_list"); + const root = $2("sa_chats_list"); if (!root) { return; } @@ -2908,27 +2911,33 @@ ${patch.prompt}`; bits.push(`LoRA ${c.params.loras.length}`); } const noParams = !c.params ? " \xB7 \u0431\u0435\u0437 \u0441\u043D\u0438\u043C\u043A\u0430 params" : ""; - row.innerHTML = ``; + row.innerHTML = ``; root.appendChild(row); } } function setChatsPanelOpen(open) { state.chatsPanelOpen = !!open; - const panel = $("sa_chats_panel"); - const btn = $("sa_btn_chats"); + state.chatsDrawerOpen = state.chatsPanelOpen; + const panel = $2("sa_chats_panel"); + const btn = $2("sa_btn_chats"); + const root = $2("swarm_assistent_root"); if (panel) { panel.hidden = !state.chatsPanelOpen; } btn?.setAttribute("aria-expanded", state.chatsPanelOpen ? "true" : "false"); + btn?.classList.toggle("sa-sessions-toggle-active", state.chatsPanelOpen); + root?.classList.toggle("sa-drawer-open", state.chatsPanelOpen); + localStorage.setItem(LS_CHATS_DRAWER, state.chatsPanelOpen ? "1" : "0"); if (state.chatsPanelOpen) { saveActiveChatToStore(); - const search = $("sa_chats_search"); + const search = $2("sa_chats_search"); if (search) { search.value = state.chatsQuery || ""; search.focus(); } renderChatsList(); } + saveUiStateToDisk(); } async function startNewChat({ saveCurrent = true, force = false } = {}) { if (!force && (state.busy || state.generating)) { @@ -2938,7 +2947,6 @@ ${patch.prompt}`; if (force) { abortInFlightWork({ status: "" }); } - setChatsPanelOpen(false); if (saveCurrent) { saveActiveChatToStore({ dropEmpty: true }); } @@ -2980,7 +2988,6 @@ ${patch.prompt}`; } async function switchToChat(id) { if (!id || id === state.activeChatId) { - setChatsPanelOpen(false); return; } if (state.busy || state.generating) { @@ -3028,7 +3035,6 @@ ${patch.prompt}`; updateSessionLabel(); syncHistoryBadge(); renderChatsList(); - setChatsPanelOpen(false); setView("chat"); if (result?.restored) { setStatus(`\u0427\u0430\u0442 \xAB${chat.title}\xBB \xB7 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u043E\u0432\u043B\u0435\u043D\u044B`); @@ -3123,7 +3129,7 @@ ${patch.prompt}`; renderBoard(); } function hideSlashMenu() { - const menu = $("sa_slash_menu"); + const menu = $2("sa_slash_menu"); if (menu) { menu.hidden = true; menu.innerHTML = ""; @@ -3139,7 +3145,7 @@ ${patch.prompt}`; return SLASH_COMMANDS.filter((c) => c.cmd.toLowerCase().startsWith(q) || q === "/" || c.cmd.toLowerCase().includes(q.slice(1))); } function renderSlashMenu(items) { - const menu = $("sa_slash_menu"); + const menu = $2("sa_slash_menu"); if (!menu) { return; } @@ -3155,7 +3161,7 @@ ${patch.prompt}`; btn.type = "button"; btn.className = "sa-slash-item" + (i === state.slashIndex ? " sa-slash-active" : ""); btn.setAttribute("role", "option"); - btn.innerHTML = `${escapeHtml(item.cmd.trim())} \u2014 ${escapeHtml(item.hint)}`; + btn.innerHTML = `${escapeHtml2(item.cmd.trim())} \u2014 ${escapeHtml2(item.hint)}`; btn.addEventListener("mousedown", (e) => { e.preventDefault(); applySlashPick(item); @@ -3164,7 +3170,7 @@ ${patch.prompt}`; }); } function applySlashPick(item) { - const input = $("sa_input"); + const input = $2("sa_input"); if (!input || !item) { return; } @@ -3175,7 +3181,7 @@ ${patch.prompt}`; input.setSelectionRange(pos, pos); } function updateSlashMenuFromInput() { - const text = $("sa_input")?.value || ""; + const text = $2("sa_input")?.value || ""; if (!text.startsWith("/") || text.includes("\n") || /\s/.test(text.trim().slice(1)) && !text.endsWith(" ")) { const token2 = text.split(/\s/)[0] || ""; if (!token2.startsWith("/") || text.includes(" ") && !SLASH_COMMANDS.some((c) => c.cmd.startsWith(token2))) { @@ -3193,7 +3199,7 @@ ${patch.prompt}`; renderSlashMenu(slashMatches(token)); } function onPersonaChanged() { - const id = $("sa_persona")?.value || "neutral"; + const id = $2("sa_persona")?.value || "neutral"; state.sessionExact = {}; state.lastUserParamIntent = false; saveSettings(); @@ -3204,10 +3210,10 @@ ${patch.prompt}`; } appendSystemNote(`\u0422\u043E\u043D \u2192 ${title}`); state.pendingPersonaNote = `Persona is now ${id} (${title}). Adopt this voice from now on.`; - if (data?.assistant?.default_pack && $("sa_pack") && !state.packUserTouched) { + if (data?.assistant?.default_pack && $2("sa_pack") && !state.packUserTouched) { const packId = data.assistant.default_pack; - if ([...$("sa_pack").options || []].some((o) => o.value === packId)) { - $("sa_pack").value = packId; + if ([...$2("sa_pack").options || []].some((o) => o.value === packId)) { + $2("sa_pack").value = packId; } } fillEmptyParamsFromExact(); @@ -3434,9 +3440,9 @@ ${patch.prompt}`; })), selected_gen_result: state.selectedGenResultId || null, has_civitai_key: !!inv.has_civitai_key, - auto_apply: !!$("sa_auto_apply")?.checked, - auto_generate: !!$("sa_auto_generate")?.checked, - persona: $("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral", + auto_apply: !!$2("sa_auto_apply")?.checked, + auto_generate: !!$2("sa_auto_generate")?.checked, + persona: $2("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral", model_cards: [], user_prefs_count: 0, ...initCtx @@ -3828,8 +3834,8 @@ ${patch.prompt}`; setStatus("\u041F\u0430\u0442\u0447\u0438 \u0443\u0431\u0440\u0430\u043D\u044B \u0438\u0437 \u0447\u0430\u0442\u0430"); } function toggleMoreMenu(menuId, btnId) { - const menu = $(menuId); - const btn = $(btnId); + const menu = $2(menuId); + const btn = $2(btnId); if (!menu) { return; } @@ -3850,7 +3856,7 @@ ${patch.prompt}`; document.querySelectorAll("#sa_btn_board_more, #sa_btn_clear_more").forEach((b) => b.setAttribute("aria-expanded", "false")); } function setPackValue(packName, { flash, user } = {}) { - const pack = $("sa_pack"); + const pack = $2("sa_pack"); if (!pack || !packName) { return false; } @@ -3876,7 +3882,7 @@ ${patch.prompt}`; if (state.packUserTouched) { return null; } - const cur = $("sa_pack")?.value || defaultPackId(); + const cur = $2("sa_pack")?.value || defaultPackId(); if (cur === "ordinary") { return null; } @@ -3911,7 +3917,7 @@ ${patch.prompt}`; if (state.packUserTouched) { return; } - const cur = $("sa_pack")?.value || ""; + const cur = $2("sa_pack")?.value || ""; if (cur === "critique_image" || cur === "describe_ref") { setPackValue(defaultPackId(), { flash: true }); } @@ -4226,15 +4232,15 @@ ${patch.prompt}`; accent: p.accent, source: p.source })); - renderPersonaOptions(state.personas, preferId || $("sa_persona")?.value); + renderPersonaOptions(state.personas, preferId || $2("sa_persona")?.value); } - if (preferId && $("sa_persona")) { - if ([...$("sa_persona").options].some((o) => o.value === preferId)) { - $("sa_persona").value = preferId; + if (preferId && $2("sa_persona")) { + if ([...$2("sa_persona").options].some((o) => o.value === preferId)) { + $2("sa_persona").value = preferId; await applyPersonaForChat(preferId, { quiet: true }); } } else { - loadConfig($("sa_persona")?.value, () => resolve()); + loadConfig($2("sa_persona")?.value, () => resolve()); return; } resolve(); @@ -4261,16 +4267,16 @@ ${patch.prompt}`; return false; } function shouldParkLlmBeforeGen() { - return !!$("sa_park_llm")?.checked; + return !!$2("sa_park_llm")?.checked; } function parkLlm() { return new Promise((resolve) => { - const model = $("sa_model")?.value; + const model = $2("sa_model")?.value; if (!shouldParkLlmBeforeGen() || !model || state.llmParked || typeof genericRequest !== "function") { resolve(false); return; } - const baseUrl = $("sa_base_url")?.value || "http://127.0.0.1:11434"; + const baseUrl = $2("sa_base_url")?.value || "http://127.0.0.1:11434"; let settled = false; const finish = (ok) => { if (settled) { @@ -4289,7 +4295,7 @@ ${patch.prompt}`; } function warmLlm({ force = false } = {}) { return new Promise((resolve) => { - const model = $("sa_model")?.value; + const model = $2("sa_model")?.value; if (!model || typeof genericRequest !== "function") { resolve(false); return; @@ -4298,7 +4304,7 @@ ${patch.prompt}`; resolve(false); return; } - const baseUrl = $("sa_base_url")?.value || "http://127.0.0.1:11434"; + const baseUrl = $2("sa_base_url")?.value || "http://127.0.0.1:11434"; let settled = false; const finish = (ok) => { if (settled) { @@ -4514,7 +4520,7 @@ ${patch.prompt}`; } async function runGenerateFromPatch(patch, opts = {}) { const force = !!opts.force; - if (!force && !$("sa_auto_generate")?.checked || !patchHasGenTrigger(patch)) { + if (!force && !$2("sa_auto_generate")?.checked || !patchHasGenTrigger(patch)) { return null; } const variantItems = normalizeVariantList(patch); @@ -4610,7 +4616,7 @@ ${patch.prompt}`; } const paneVisible = !!document.getElementById("swarm_assistent_root")?.offsetParent; const multiDone = !!(jobs && finishedGenResultCount() > 1); - const willAutoCritique = !multiDone && !!$("sa_auto_critique")?.checked; + const willAutoCritique = !multiDone && !!$2("sa_auto_critique")?.checked; if (state.view === "chat" && paneVisible && !willAutoCritique && epoch === state.chatEpoch) { startBusyUi("warming"); setStatus("\u0412\u043E\u0437\u0432\u0440\u0430\u0449\u0430\u044E LLM \u0432 GPU\u2026"); @@ -4660,7 +4666,7 @@ ${patch.prompt}`; return src && !looksLikeModelPreview(src) ? src : null; } async function maybeAutoCritique(imageSrc) { - if (!$("sa_auto_critique")?.checked || turnHopUsed("critique") || isMultiGenResults()) { + if (!$2("sa_auto_critique")?.checked || turnHopUsed("critique") || isMultiGenResults()) { return; } const src = await resolveFinishedGenerateSrc(imageSrc); @@ -4672,8 +4678,8 @@ ${patch.prompt}`; return; } setPackValue("critique_image", { flash: true }); - if ($("sa_input")) { - $("sa_input").value = "Critique this result and improve the prompt for the next generation."; + if ($2("sa_input")) { + $2("sa_input").value = "Critique this result and improve the prompt for the next generation."; } const gen = generateSlot(); if (gen) { @@ -4686,7 +4692,7 @@ ${patch.prompt}`; restoreDefaultPackAfterHop(); } async function maybeAutoVisionLook(imageSrc) { - if (!wantsAutoVision() || $("sa_auto_critique")?.checked || turnHopUsed("vision") || state.busy || isMultiGenResults()) { + if (!wantsAutoVision() || $2("sa_auto_critique")?.checked || turnHopUsed("vision") || state.busy || isMultiGenResults()) { return; } const src = await resolveFinishedGenerateSrc(imageSrc); @@ -4703,8 +4709,8 @@ ${patch.prompt}`; return; } setPackValue("critique_image", { flash: true }); - if ($("sa_input")) { - $("sa_input").value = "Look at the Generate result and briefly say what worked and what to fix next."; + if ($2("sa_input")) { + $2("sa_input").value = "Look at the Generate result and briefly say what worked and what to fix next."; } setStatus("Auto look_at\u2026"); await sendChat({ fromVisionHop: true, forceSlotIds: [GEN_ID], skipAutoPack: true }); @@ -4735,13 +4741,13 @@ ${patch.prompt}`; setView("chat"); const label = (state.genResults || []).find((r) => r.id === state.selectedGenResultId)?.label; setPackValue("critique_image", { flash: true }); - if ($("sa_input")) { - $("sa_input").value = label ? `\u041F\u043E\u0441\u043C\u043E\u0442\u0440\u0438 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442 \xAB${label}\xBB: \u0447\u0442\u043E \u043F\u043E\u043B\u0443\u0447\u0438\u043B\u043E\u0441\u044C, \u0447\u0442\u043E \u0441\u043B\u043E\u043C\u0430\u043B\u043E\u0441\u044C, \u0438 \u043A\u0430\u043A \u043F\u043E\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043F\u0440\u043E\u043C\u043F\u0442 \u0438 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0434\u043B\u044F \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u0433\u043E \u043A\u0430\u0434\u0440\u0430.` : "\u041F\u043E\u0441\u043C\u043E\u0442\u0440\u0438 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442: \u0447\u0442\u043E \u043F\u043E\u043B\u0443\u0447\u0438\u043B\u043E\u0441\u044C, \u0447\u0442\u043E \u0441\u043B\u043E\u043C\u0430\u043B\u043E\u0441\u044C, \u0438 \u043A\u0430\u043A \u043F\u043E\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043F\u0440\u043E\u043C\u043F\u0442 \u0438 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0434\u043B\u044F \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u0433\u043E \u043A\u0430\u0434\u0440\u0430."; + if ($2("sa_input")) { + $2("sa_input").value = label ? `\u041F\u043E\u0441\u043C\u043E\u0442\u0440\u0438 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442 \xAB${label}\xBB: \u0447\u0442\u043E \u043F\u043E\u043B\u0443\u0447\u0438\u043B\u043E\u0441\u044C, \u0447\u0442\u043E \u0441\u043B\u043E\u043C\u0430\u043B\u043E\u0441\u044C, \u0438 \u043A\u0430\u043A \u043F\u043E\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043F\u0440\u043E\u043C\u043F\u0442 \u0438 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0434\u043B\u044F \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u0433\u043E \u043A\u0430\u0434\u0440\u0430.` : "\u041F\u043E\u0441\u043C\u043E\u0442\u0440\u0438 \u0440\u0435\u0437\u0443\u043B\u044C\u0442\u0430\u0442: \u0447\u0442\u043E \u043F\u043E\u043B\u0443\u0447\u0438\u043B\u043E\u0441\u044C, \u0447\u0442\u043E \u0441\u043B\u043E\u043C\u0430\u043B\u043E\u0441\u044C, \u0438 \u043A\u0430\u043A \u043F\u043E\u043F\u0440\u0430\u0432\u0438\u0442\u044C \u043F\u0440\u043E\u043C\u043F\u0442 \u0438 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B \u0434\u043B\u044F \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u0433\u043E \u043A\u0430\u0434\u0440\u0430."; } await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true }); } function currentPersonaInfo() { - const id = ($("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral").trim() || "neutral"; + const id = ($2("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral").trim() || "neutral"; const known = (state.personas || []).find((p) => p && p.id === id); return { id, @@ -4757,7 +4763,7 @@ ${patch.prompt}`; return; } const persona = meta.persona || currentPersonaInfo(); - const pack = meta.pack || $("sa_pack")?.value || ""; + const pack = meta.pack || $2("sa_pack")?.value || ""; div.dataset.persona = persona.id || "neutral"; if (pack) { div.dataset.pack = pack; @@ -4779,7 +4785,7 @@ ${patch.prompt}`; div.insertBefore(row, div.firstChild); } function appendMessage(role, text, patch, civitaiResults, meta) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return null; } @@ -4803,12 +4809,15 @@ ${patch.prompt}`; if (civitaiResults && civitaiResults.length) { div.appendChild(buildCivitaiCards(civitaiResults)); } + if (role === "assistant" && !(meta && meta.historical)) { + mountCurateButtons(div, meta); + } box.appendChild(div); scrollMessagesToBottom({ force: true }); return div; } function beginStreamMessage(meta) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return null; } @@ -4921,8 +4930,74 @@ ${patch.prompt}`; if (civitaiResults && civitaiResults.length) { el.appendChild(buildCivitaiCards(civitaiResults)); } + if (!(meta && meta.historical)) { + mountCurateButtons(el, meta); + } scrollMessagesToBottom(); } + function mountCurateButtons(msgEl, meta) { + if (!msgEl || msgEl.querySelector(".sa-msg-curate")) { + return; + } + const wrap = document.createElement("div"); + wrap.className = "sa-msg-curate"; + const ok = document.createElement("button"); + ok.type = "button"; + ok.className = "basic-button"; + ok.title = "\u0412 \u0434\u0430\u0442\u0430\u0441\u0435\u0442 (\u043E\u0434\u043E\u0431\u0440\u0438\u0442\u044C)"; + ok.textContent = "+ \u0434\u0430\u0442\u0430\u0441\u0435\u0442"; + ok.addEventListener("click", () => curateAssistantMessage(msgEl, "approved")); + const bad = document.createElement("button"); + bad.type = "button"; + bad.className = "basic-button"; + bad.title = "\u041E\u0442\u043A\u043B\u043E\u043D\u0438\u0442\u044C \u0434\u043B\u044F \u0434\u0430\u0442\u0430\u0441\u0435\u0442\u0430"; + bad.textContent = "\u0431\u0440\u0430\u043A"; + bad.addEventListener("click", () => curateAssistantMessage(msgEl, "rejected")); + wrap.appendChild(ok); + wrap.appendChild(bad); + msgEl.appendChild(wrap); + } + function curateAssistantMessage(msgEl, status) { + const hist = state.history || []; + let asstText = msgEl.querySelector(".sa-msg-body")?.textContent?.trim() || msgEl.textContent?.trim() || ""; + let userText = ""; + for (let i = hist.length - 1; i >= 0; i--) { + if (hist[i]?.role === "assistant" && (hist[i].content || "").trim() === asstText.trim()) { + for (let j = i - 1; j >= 0; j--) { + if (hist[j]?.role === "user") { + userText = hist[j].content || ""; + break; + } + } + break; + } + } + if (!userText) { + for (let i = hist.length - 1; i >= 0; i--) { + if (hist[i]?.role === "user") { + userText = hist[i].content || ""; + break; + } + } + } + const messages = [ + { role: "user", content: userText }, + { role: "assistant", content: asstText } + ]; + window.SA?.training?.curateFromChat?.(messages, { + chatId: state.activeChatId, + persona: $2("sa_persona")?.value || "neutral", + pack: $2("sa_pack")?.value || defaultPackId(), + status + })?.then?.((ok) => { + if (ok) { + setStatus(status === "approved" ? "\u041F\u0440\u0438\u043C\u0435\u0440 \u0434\u043E\u0431\u0430\u0432\u043B\u0435\u043D \u0432 \u0434\u0430\u0442\u0430\u0441\u0435\u0442" : "\u041F\u0440\u0438\u043C\u0435\u0440 \u043E\u0442\u043C\u0435\u0447\u0435\u043D \u043A\u0430\u043A \u0431\u0440\u0430\u043A"); + } + }); + } + function isTrainingLocked() { + return !!state.trainingLock || document.getElementById("swarm_assistent_root")?.classList.contains("sa-root-training-lock"); + } function buildCivitaiCards(results) { const list = document.createElement("div"); list.className = "sa-civitai-list"; @@ -5039,8 +5114,8 @@ ${patch.prompt}`; version_id: civitai?.version_id || civitai?.modelVersionId, name: display }; - if ($("sa_input")) { - $("sa_input").value = ""; + if ($2("sa_input")) { + $2("sa_input").value = ""; } await sendChat({ forcedUserText: `LoRA "${display}" is now installed. Write a recommendation card (JSON) using its triggers/metadata. Then briefly suggest how to enable it in the next generate.`, @@ -5052,7 +5127,7 @@ ${patch.prompt}`; }); } function wantsAutoVision() { - return !!$("sa_auto_vision")?.checked; + return !!$2("sa_auto_vision")?.checked; } function looksLikeModelPreview(src) { const s = String(src || "").toLowerCase(); @@ -5239,32 +5314,32 @@ ${patch.prompt}`; const autoDl = localStorage.getItem(LS_AUTO_DOWNLOAD); const parkLlm2 = localStorage.getItem(LS_PARK_LLM); const paneW = localStorage.getItem(LS_PANE_WIDTH); - if (base && $("sa_base_url")) { - $("sa_base_url").value = base; + if (base && $2("sa_base_url")) { + $2("sa_base_url").value = base; } - if (pack && $("sa_pack")) { - $("sa_pack").value = pack; + if (pack && $2("sa_pack")) { + $2("sa_pack").value = pack; } - if (persona && $("sa_persona")) { - $("sa_persona").value = persona; + if (persona && $2("sa_persona")) { + $2("sa_persona").value = persona; } - if (auto != null && $("sa_auto_vision")) { - $("sa_auto_vision").checked = auto === "1"; + if (auto != null && $2("sa_auto_vision")) { + $2("sa_auto_vision").checked = auto === "1"; } - if ($("sa_auto_apply")) { - $("sa_auto_apply").checked = autoApply == null ? true : autoApply === "1"; + if ($2("sa_auto_apply")) { + $2("sa_auto_apply").checked = autoApply == null ? true : autoApply === "1"; } - if ($("sa_auto_generate")) { - $("sa_auto_generate").checked = autoGen == null ? true : autoGen === "1"; + if ($2("sa_auto_generate")) { + $2("sa_auto_generate").checked = autoGen == null ? true : autoGen === "1"; } - if ($("sa_auto_critique") && autoCrit != null) { - $("sa_auto_critique").checked = autoCrit === "1"; + if ($2("sa_auto_critique") && autoCrit != null) { + $2("sa_auto_critique").checked = autoCrit === "1"; } - if ($("sa_auto_download") && autoDl != null) { - $("sa_auto_download").checked = autoDl === "1"; + if ($2("sa_auto_download") && autoDl != null) { + $2("sa_auto_download").checked = autoDl === "1"; } - if ($("sa_park_llm")) { - $("sa_park_llm").checked = parkLlm2 === "1"; + if ($2("sa_park_llm")) { + $2("sa_park_llm").checked = parkLlm2 === "1"; } if (model) { state.preferredModel = model; @@ -5276,9 +5351,14 @@ ${patch.prompt}`; if (paneW) { document.documentElement.style.setProperty("--sa-image-width", paneW); } - if (view === "cards" || view === "chat" || view === "settings") { + if (view === "cards" || view === "chat" || view === "settings" || view === "train") { state.view = view; } + const drawer = localStorage.getItem(LS_CHATS_DRAWER); + if (drawer != null) { + state.chatsDrawerOpen = drawer === "1"; + state.chatsPanelOpen = state.chatsDrawerOpen; + } const boardTab = localStorage.getItem(LS_BOARD_TAB); if (boardTab === "refs" || boardTab === "generate") { state.boardTab = boardTab; @@ -5286,20 +5366,21 @@ ${patch.prompt}`; } function collectUiState() { return { - pack: $("sa_pack")?.value || defaultPackId(), - 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, - park_llm: !!$("sa_park_llm")?.checked, + pack: $2("sa_pack")?.value || defaultPackId(), + persona: $2("sa_persona")?.value || "neutral", + auto_vision: !!$2("sa_auto_vision")?.checked, + auto_apply: !!$2("sa_auto_apply")?.checked, + auto_generate: !!$2("sa_auto_generate")?.checked, + auto_critique: !!$2("sa_auto_critique")?.checked, + auto_download: !!$2("sa_auto_download")?.checked, + park_llm: !!$2("sa_park_llm")?.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 || "", + embed_model: $2("sa_embed_model")?.value || state.preferredEmbed || "", + base_url: $2("sa_base_url")?.value || "", + model: $2("sa_model")?.value || "", view: state.view || "chat", - board_tab: state.boardTab || "generate" + board_tab: state.boardTab || "generate", + chats_drawer: state.chatsDrawerOpen ? "1" : "0" }; } async function applyDiskUiState() { @@ -5324,8 +5405,8 @@ ${patch.prompt}`; apply?.(String(value)); }; fill(LS_BASE, ui.base_url, (v) => { - if ($("sa_base_url")) { - $("sa_base_url").value = v; + if ($2("sa_base_url")) { + $2("sa_base_url").value = v; } }); fill(LS_MODEL, ui.model, (v) => { @@ -5335,17 +5416,17 @@ ${patch.prompt}`; state.preferredEmbed = v; }); fill(LS_PACK, ui.pack, (v) => { - if ($("sa_pack")) { - $("sa_pack").value = v; + if ($2("sa_pack")) { + $2("sa_pack").value = v; } }); fill(LS_PERSONA, ui.persona, (v) => { - if ($("sa_persona")) { - $("sa_persona").value = v; + if ($2("sa_persona")) { + $2("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" || ui.view === "settings") { + if (ui.view === "cards" || ui.view === "chat" || ui.view === "settings" || ui.view === "train") { fill(LS_VIEW, ui.view, (v) => { state.view = v; }); @@ -5355,6 +5436,12 @@ ${patch.prompt}`; state.boardTab = v; }); } + if (ui.chats_drawer != null && localStorage.getItem(LS_CHATS_DRAWER) == null) { + const open = ui.chats_drawer === true || ui.chats_drawer === "1" || ui.chats_drawer === 1; + localStorage.setItem(LS_CHATS_DRAWER, open ? "1" : "0"); + state.chatsDrawerOpen = open; + state.chatsPanelOpen = open; + } for (const [key, lsKey, id] of [ ["auto_vision", LS_AUTO_VISION, "sa_auto_vision"], ["auto_apply", LS_AUTO_APPLY, "sa_auto_apply"], @@ -5371,7 +5458,7 @@ ${patch.prompt}`; continue; } localStorage.setItem(lsKey, on ? "1" : "0"); - const el = $(id); + const el = $2(id); if (el) { el.checked = on; } @@ -5381,18 +5468,18 @@ ${patch.prompt}`; diskPersist()?.saveUiState(collectUiState()); } function saveSettings() { - localStorage.setItem(LS_BASE, $("sa_base_url")?.value || ""); - localStorage.setItem(LS_MODEL, $("sa_model")?.value || ""); - localStorage.setItem(LS_EMBED, $("sa_embed_model")?.value || state.preferredEmbed || ""); - localStorage.setItem(LS_PACK, $("sa_pack")?.value || defaultPackId()); - localStorage.setItem(LS_PERSONA, $("sa_persona")?.value || "neutral"); + localStorage.setItem(LS_BASE, $2("sa_base_url")?.value || ""); + localStorage.setItem(LS_MODEL, $2("sa_model")?.value || ""); + localStorage.setItem(LS_EMBED, $2("sa_embed_model")?.value || state.preferredEmbed || ""); + localStorage.setItem(LS_PACK, $2("sa_pack")?.value || defaultPackId()); + localStorage.setItem(LS_PERSONA, $2("sa_persona")?.value || "neutral"); localStorage.setItem(LS_VIEW, state.view || "chat"); - localStorage.setItem(LS_AUTO_VISION, $("sa_auto_vision")?.checked ? "1" : "0"); - localStorage.setItem(LS_AUTO_APPLY, $("sa_auto_apply")?.checked ? "1" : "0"); - localStorage.setItem(LS_AUTO_GENERATE, $("sa_auto_generate")?.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_PARK_LLM, $("sa_park_llm")?.checked ? "1" : "0"); + localStorage.setItem(LS_AUTO_VISION, $2("sa_auto_vision")?.checked ? "1" : "0"); + localStorage.setItem(LS_AUTO_APPLY, $2("sa_auto_apply")?.checked ? "1" : "0"); + localStorage.setItem(LS_AUTO_GENERATE, $2("sa_auto_generate")?.checked ? "1" : "0"); + localStorage.setItem(LS_AUTO_CRITIQUE, $2("sa_auto_critique")?.checked ? "1" : "0"); + localStorage.setItem(LS_AUTO_DOWNLOAD, $2("sa_auto_download")?.checked ? "1" : "0"); + localStorage.setItem(LS_PARK_LLM, $2("sa_park_llm")?.checked ? "1" : "0"); persistServerSettings(); saveUiStateToDisk(); } @@ -5404,10 +5491,10 @@ ${patch.prompt}`; document.querySelectorAll("#sa_skills_box input[data-skill]")?.forEach((el) => { skills[el.getAttribute("data-skill")] = !!el.checked; }); - const persona = $("sa_persona")?.value || "neutral"; + const persona = $2("sa_persona")?.value || "neutral"; const settings = { - embed_model: $("sa_embed_model")?.value || state.preferredEmbed || "", - base_url: $("sa_base_url")?.value || "", + embed_model: $2("sa_embed_model")?.value || state.preferredEmbed || "", + base_url: $2("sa_base_url")?.value || "", [persona]: { skills } }; genericRequest("AssistentSaveSettings", { settings }, () => { @@ -5418,7 +5505,7 @@ ${patch.prompt}`; if (!data || data.error) { return; } - const prevPersona = state.config?.persona || $("sa_persona")?.value || ""; + const prevPersona = state.config?.persona || $2("sa_persona")?.value || ""; const prevControls = state.config?.control_values && typeof state.config.control_values === "object" ? { ...state.config.control_values } : null; state.config = data; if (window.SA?.applyConfigPatchKeys) { @@ -5477,8 +5564,8 @@ ${data.ui.help_extra}`.trim(); renderPackOptions(data.packs || [], applyDefaults ? data.assistant?.default_pack : null); renderChips(data.ui?.chips || []); renderSkillChecks(data.skills || [], state.enabledSkills); - if (applyDefaults && data.assistant?.default_pack && $("sa_pack") && !localStorage.getItem(LS_PACK)) { - $("sa_pack").value = data.assistant.default_pack; + if (applyDefaults && data.assistant?.default_pack && $2("sa_pack") && !localStorage.getItem(LS_PACK)) { + $2("sa_pack").value = data.assistant.default_pack; } if (data.assistant?.embed_model && !state.preferredEmbed) { state.preferredEmbed = data.assistant.embed_model; @@ -5506,7 +5593,7 @@ ${data.ui.help_extra}`.trim(); if (applyDefaults || data.exact) { fillEmptyParamsFromExact(); } - const nextPersona = data.persona || $("sa_persona")?.value || ""; + const nextPersona = data.persona || $2("sa_persona")?.value || ""; let controlValues = data.control_values || data.exact?.controls || {}; if (!applyDefaults && prevControls && nextPersona === prevPersona) { controlValues = { ...controlValues, ...prevControls }; @@ -5516,10 +5603,10 @@ ${data.ui.help_extra}`.trim(); } } renderPersonaControls(data.controls || {}, controlValues); - syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $("sa_persona")?.value))?.source); + syncPersonaDeleteButton(data.persona_source || data.personas?.find((p) => p.id === (data.persona || $2("sa_persona")?.value))?.source); } function syncPersonaDeleteButton(source) { - const btn = $("sa_persona_delete"); + const btn = $2("sa_persona_delete"); if (!btn) { return; } @@ -5532,7 +5619,7 @@ ${data.ui.help_extra}`.trim(); let controlsPointerDown = false; let pendingControlsRender = null; function renderPersonaControls(schema, values) { - const box = $("sa_persona_controls"); + const box = $2("sa_persona_controls"); if (!box) { return; } @@ -5643,7 +5730,7 @@ ${data.ui.help_extra}`.trim(); return Number.isFinite(n) ? n : fallback; } function coolDownHorny() { - const persona = $("sa_persona")?.value || ""; + const persona = $2("sa_persona")?.value || ""; if (persona !== "leonid") { setStatus("/\u043E\u0441\u0442\u044B\u043D\u044C \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F Leonid"); return; @@ -5674,7 +5761,7 @@ ${data.ui.help_extra}`.trim(); setStatus(`/\u043E\u0441\u0442\u044B\u043D\u044C \u2192 ${Math.round(next)}%`); } async function startHornyGame() { - const persona = $("sa_persona")?.value || ""; + const persona = $2("sa_persona")?.value || ""; if (persona !== "leonid") { setStatus("/horny-game \u0442\u043E\u043B\u044C\u043A\u043E \u0434\u043B\u044F Leonid"); return; @@ -5692,7 +5779,7 @@ ${data.ui.help_extra}`.trim(); setStatus("/horny-game\u2026"); } function savePersonaControls(partial) { - const persona = $("sa_persona")?.value || "neutral"; + const persona = $2("sa_persona")?.value || "neutral"; if (typeof genericRequest !== "function") { return; } @@ -5727,7 +5814,7 @@ ${data.ui.help_extra}`.trim(); ); } function syncPersonaControlInputs(values) { - const box = $("sa_persona_controls"); + const box = $2("sa_persona_controls"); if (!box || !values || typeof values !== "object") { return; } @@ -5750,7 +5837,7 @@ ${data.ui.help_extra}`.trim(); }); } async function deleteCurrentOverlayPersona() { - const id = $("sa_persona")?.value; + const id = $2("sa_persona")?.value; if (!id) { return; } @@ -5780,8 +5867,8 @@ ${data.ui.help_extra}`.trim(); state.personas = data.personas; } renderPersonaOptions(state.personas || [], next); - if ($("sa_persona")) { - $("sa_persona").value = next; + if ($2("sa_persona")) { + $2("sa_persona").value = next; } await applyPersonaForChat(next, { quiet: false }); setStatus(`\u0423\u0434\u0430\u043B\u0435\u043D\u043E: ${id}`); @@ -5796,7 +5883,7 @@ ${data.ui.help_extra}`.trim(); }); } function renderPersonaOptions(personas, selected) { - const sel = $("sa_persona"); + const sel = $2("sa_persona"); if (!sel) { return; } @@ -5818,7 +5905,7 @@ ${data.ui.help_extra}`.trim(); syncPersonaDeleteButton(meta?.source || state.config?.persona_source); } function renderPackOptions(packs, preferred) { - const sel = $("sa_pack"); + const sel = $2("sa_pack"); if (!sel) { return; } @@ -5836,7 +5923,7 @@ ${data.ui.help_extra}`.trim(); } } function renderChips(chips) { - const box = $("sa_chips"); + const box = $2("sa_chips"); if (!box || !Array.isArray(chips) || !chips.length) { return; } @@ -5871,7 +5958,7 @@ ${data.ui.help_extra}`.trim(); } } function renderSkillChecks(skills, enabled) { - const box = $("sa_skills_box"); + const box = $2("sa_skills_box"); if (!box) { return; } @@ -5901,7 +5988,7 @@ ${data.ui.help_extra}`.trim(); } genericRequest( "AssistentGetConfig", - { persona: persona || $("sa_persona")?.value || "neutral" }, + { persona: persona || $2("sa_persona")?.value || "neutral" }, (data) => { applyConfigPayload(data, { applyDefaults: true }); done?.(data); @@ -5948,8 +6035,8 @@ ${data.ui.help_extra}`.trim(); return preferred || list[0]; } function setModelOptions(models, { error, preferred } = {}) { - const sel = $("sa_model"); - const sel2 = $("sa_settings_chat_model"); + const sel = $2("sa_model"); + const sel2 = $2("sa_settings_chat_model"); const apply = (target) => { if (!target) { return; @@ -5988,7 +6075,7 @@ ${data.ui.help_extra}`.trim(); apply(sel2); } function setEmbedModelOptions(models) { - const sel = $("sa_embed_model"); + const sel = $2("sa_embed_model"); if (!sel) { return; } @@ -6019,7 +6106,7 @@ ${data.ui.help_extra}`.trim(); } } function refreshModels() { - const baseUrl = $("sa_base_url")?.value || "http://127.0.0.1:11434"; + const baseUrl = $2("sa_base_url")?.value || "http://127.0.0.1:11434"; setStatus("Loading models\u2026"); if (typeof genericRequest !== "function") { setStatus("SwarmUI API not ready"); @@ -6036,10 +6123,10 @@ ${data.ui.help_extra}`.trim(); setModelOptions(models, { preferred }); setEmbedModelOptions(memoryModels); const pick = resolveChatModel(models, preferred); - if (pick && $("sa_model")) { - $("sa_model").value = pick; - if ($("sa_settings_chat_model")) { - $("sa_settings_chat_model").value = pick; + if (pick && $2("sa_model")) { + $2("sa_model").value = pick; + if ($2("sa_settings_chat_model")) { + $2("sa_settings_chat_model").value = pick; } state.preferredModel = pick; localStorage.setItem(LS_MODEL, pick); @@ -6107,16 +6194,16 @@ ${data.ui.help_extra}`.trim(); return new Promise((resolve) => refreshInventory(resolve, opts)); } function memoryKindFilter() { - return $("sa_mem_kind")?.value || "all"; + return $2("sa_mem_kind")?.value || "all"; } function memoryScopeFilter() { - return $("sa_mem_scope")?.value || "all"; + return $2("sa_mem_scope")?.value || "all"; } function memorySearchFilter() { - return ($("sa_mem_search")?.value || "").trim().toLowerCase(); + return ($2("sa_mem_search")?.value || "").trim().toLowerCase(); } function renderMemoryKinds(kinds) { - const sel = $("sa_mem_kind"); + const sel = $2("sa_mem_kind"); if (!sel) { return; } @@ -6140,7 +6227,7 @@ ${data.ui.help_extra}`.trim(); const filter = memoryKindFilter(); const scope = memoryScopeFilter(); const q = memorySearchFilter(); - const persona = $("sa_persona")?.value || "neutral"; + const persona = $2("sa_persona")?.value || "neutral"; return (state.memoryRows || []).filter((m) => { if (filter !== "all" && m.kind !== filter) { return false; @@ -6161,7 +6248,7 @@ ${data.ui.help_extra}`.trim(); }); } function renderMemoryList() { - const root = $("sa_mem_list"); + const root = $2("sa_mem_list"); if (!root) { return; } @@ -6177,7 +6264,7 @@ ${data.ui.help_extra}`.trim(); const bundled = row.source === "bundled"; const when = row.updated ? formatChatWhen(row.updated * 1e3) : ""; const scope = row.scope === "personal" ? `\u043F\u0435\u0440\u0441\u043E\u043D\u0430 ${row.persona || "\u2014"}` : "\u043E\u0431\u0449\u0430\u044F"; - el.innerHTML = `
${escapeHtml(row.kind || "note")}${escapeHtml(row.key || "")}
${escapeHtml(clipDebug(row.text, 220))}
${escapeHtml([scope, row.source || "user", when].filter(Boolean).join(" \xB7 "))}
`; + el.innerHTML = `
${escapeHtml2(row.kind || "note")}${escapeHtml2(row.key || "")}
${escapeHtml2(clipDebug(row.text, 220))}
${escapeHtml2([scope, row.source || "user", when].filter(Boolean).join(" \xB7 "))}
`; const forget = document.createElement("button"); forget.type = "button"; forget.className = "basic-button sa-mem-forget"; @@ -6197,7 +6284,7 @@ ${data.ui.help_extra}`.trim(); if (typeof genericRequest !== "function") { return; } - const list = $("sa_mem_list"); + const list = $2("sa_mem_list"); if (list && !state.memoryRows.length) { list.innerHTML = '
\u0427\u0438\u0442\u0430\u044E \u043F\u0430\u043C\u044F\u0442\u044C\u2026
'; } @@ -6208,7 +6295,7 @@ ${data.ui.help_extra}`.trim(); state.memoryRows = Array.isArray(data?.memories) ? data.memories : []; renderMemoryKinds(data?.kinds || []); renderMemoryList(); - const foot = $("sa_mem_total"); + const foot = $2("sa_mem_total"); if (foot) { foot.textContent = `\u0412\u0441\u0435\u0433\u043E: ${data?.total ?? state.memoryRows.length} \xB7 ${data?.embed_model || "\u2014"}`; } @@ -6216,7 +6303,7 @@ ${data.ui.help_extra}`.trim(); 0, (err) => { if (list) { - list.innerHTML = `
\u041F\u0430\u043C\u044F\u0442\u044C \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430: ${escapeHtml(String(err || "\u043E\u0448\u0438\u0431\u043A\u0430"))}
`; + list.innerHTML = `
\u041F\u0430\u043C\u044F\u0442\u044C \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u043D\u0430: ${escapeHtml2(String(err || "\u043E\u0448\u0438\u0431\u043A\u0430"))}
`; } } ); @@ -6289,9 +6376,9 @@ ${data.ui.help_extra}`.trim(); } if (state.settingsTab === "models") { syncSettingsHealthLine(); - const m = $("sa_model")?.value; - if (m && $("sa_settings_chat_model")) { - $("sa_settings_chat_model").value = m; + const m = $2("sa_model")?.value; + if (m && $2("sa_settings_chat_model")) { + $2("sa_settings_chat_model").value = m; } } if (state.settingsTab === "more") { @@ -6302,7 +6389,7 @@ ${data.ui.help_extra}`.trim(); const asst = data?.assistant || state.config?.assistant || {}; const exact = data?.exact || state.config?.exact || state.exact || {}; const setNum = (id, v) => { - const el = $(id); + const el = $2(id); if (el && v != null && Number.isFinite(Number(v))) { el.value = String(v); } @@ -6311,10 +6398,10 @@ ${data.ui.help_extra}`.trim(); setNum("sa_history_keep", asst.history_keep_turns); setNum("sa_memory_top_k", asst.memory_top_k); const w = asst.user_prefs_weight != null ? Number(asst.user_prefs_weight) : 1; - const weightEl = $("sa_user_prefs_weight"); + const weightEl = $2("sa_user_prefs_weight"); if (weightEl) { weightEl.value = String(Math.max(0, Math.min(1.5, w))); - const lab = $("sa_user_prefs_weight_val"); + const lab = $2("sa_user_prefs_weight_val"); if (lab) { lab.textContent = Number(weightEl.value).toFixed(1); } @@ -6333,7 +6420,7 @@ ${data.ui.help_extra}`.trim(); return; } const num = (id) => { - const v = parseFloat($(id)?.value); + const v = parseFloat($2(id)?.value); return Number.isFinite(v) ? v : null; }; const assistant = { @@ -6379,8 +6466,8 @@ ${data.ui.help_extra}`.trim(); ); } function syncSettingsHealthLine() { - const line = $("sa_settings_health_line"); - const badge = $("sa_ollama_health"); + const line = $2("sa_settings_health_line"); + const badge = $2("sa_ollama_health"); if (line && badge) { line.textContent = badge.textContent || "Ollama \xB7 \u2026"; line.className = "sa-settings-health " + (badge.className || "").replace("sa-health", "").trim(); @@ -6396,12 +6483,12 @@ ${data.ui.help_extra}`.trim(); return "\u0432\u0441\u0442\u0440\u043E\u0435\u043D\u043E"; } function renderPersonaSettingsList() { - const root = $("sa_persona_list"); + const root = $2("sa_persona_list"); if (!root) { return; } const list = state.personas || state.config?.personas || []; - const cur = state.settingsPersonaId || $("sa_persona")?.value || list[0]?.id; + const cur = state.settingsPersonaId || $2("sa_persona")?.value || list[0]?.id; state.settingsPersonaId = cur; root.innerHTML = ""; for (const p of list) { @@ -6409,7 +6496,7 @@ ${data.ui.help_extra}`.trim(); btn.type = "button"; btn.className = "sa-persona-item" + (p.id === cur ? " sa-persona-item-active" : ""); const accent = p.accent || "currentColor"; - btn.innerHTML = `
${escapeHtml(p.title || p.id)}
${escapeHtml(personaSourceLabel(p.source))}
`; + btn.innerHTML = `
${escapeHtml2(p.title || p.id)}
${escapeHtml2(personaSourceLabel(p.source))}
`; btn.addEventListener("click", () => { state.settingsPersonaId = p.id; renderPersonaSettingsList(); @@ -6426,13 +6513,13 @@ ${data.ui.help_extra}`.trim(); const id = state.settingsPersonaId; const p = (state.personas || []).find((x) => x.id === id); const canDelete = p && (p.source === "overlay" || p.source === "overlay+bundled"); - const del = $("sa_btn_persona_delete_panel"); + const del = $2("sa_btn_persona_delete_panel"); if (del) { del.disabled = !canDelete; } } function loadPersonaPreview(id) { - const box = $("sa_persona_preview"); + const box = $2("sa_persona_preview"); if (!box || typeof genericRequest !== "function") { return; } @@ -6450,12 +6537,12 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }, 0, (err) => { - box.innerHTML = `
${escapeHtml(String(err || "\u043E\u0448\u0438\u0431\u043A\u0430"))}
`; + box.innerHTML = `
${escapeHtml2(String(err || "\u043E\u0448\u0438\u0431\u043A\u0430"))}
`; } ); } function exportSelectedPersona() { - const id = state.settingsPersonaId || $("sa_persona")?.value; + const id = state.settingsPersonaId || $2("sa_persona")?.value; if (!id || typeof genericRequest !== "function") { return; } @@ -6516,7 +6603,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; reader.readAsText(file); } function cloneSelectedPersona() { - const from = state.settingsPersonaId || $("sa_persona")?.value; + const from = state.settingsPersonaId || $2("sa_persona")?.value; if (!from) { return; } @@ -6570,7 +6657,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (typeof genericRequest !== "function") { return; } - const persona = $("sa_persona")?.value || "neutral"; + const persona = $2("sa_persona")?.value || "neutral"; genericRequest( "AssistentListUserPrefs", { persona, limit: 200 }, @@ -6583,11 +6670,11 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; ); } function renderUserPrefsLists() { - const persona = $("sa_persona")?.value || "neutral"; + const persona = $2("sa_persona")?.value || "neutral"; const global = (state.userPrefs || []).filter((p) => p.scope === "global"); const personal = (state.userPrefs || []).filter((p) => p.scope === "persona" && (p.persona_id === persona || p.persona === persona)); const fill = (rootId, rows) => { - const root = $(rootId); + const root = $2(rootId); if (!root) { return; } @@ -6600,7 +6687,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; const el = document.createElement("div"); el.className = "sa-mem-row"; const pin = row.pinned ? " \u2605" : ""; - el.innerHTML = `
${escapeHtml(row.key || "")}${pin}
${escapeHtml(clipDebug(row.text, 200))}
`; + el.innerHTML = `
${escapeHtml2(row.key || "")}${pin}
${escapeHtml2(clipDebug(row.text, 200))}
`; el.querySelector(".sa-mem-row-body")?.addEventListener("click", () => editUserPref(row)); el.querySelector(".sa-mem-row-body")?.setAttribute("title", "\u041A\u043B\u0438\u043A \u2014 \u0440\u0435\u0434\u0430\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C"); const pinBtn = document.createElement("button"); @@ -6640,7 +6727,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; key: row.key, text: String(text).trim(), scope: row.scope || "global", - persona: row.persona_id || row.persona || $("sa_persona")?.value || "neutral", + persona: row.persona_id || row.persona || $2("sa_persona")?.value || "neutral", source: "user", pinned: !!row.pinned }, @@ -6656,7 +6743,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; key: row.key, text: row.text, scope: row.scope || "global", - persona: row.persona_id || row.persona || $("sa_persona")?.value || "neutral", + persona: row.persona_id || row.persona || $2("sa_persona")?.value || "neutral", source: row.source || "user", pinned: !row.pinned }, @@ -6680,7 +6767,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; key: key.trim(), text: text.trim(), scope, - persona: $("sa_persona")?.value || "neutral", + persona: $2("sa_persona")?.value || "neutral", source: "user", pinned: false }, @@ -6698,7 +6785,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; { key: row.key, scope: row.scope || "global", - persona: row.persona_id || row.persona || $("sa_persona")?.value + persona: row.persona_id || row.persona || $2("sa_persona")?.value }, () => refreshUserPrefs(), 0, @@ -6712,7 +6799,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; } genericRequest( "AssistentClearUserPrefs", - { scope, persona: $("sa_persona")?.value || "neutral" }, + { scope, persona: $2("sa_persona")?.value || "neutral" }, (data) => { setStatus(`\u0423\u0434\u0430\u043B\u0435\u043D\u043E: ${data?.deleted ?? 0}`); refreshUserPrefs(); @@ -6756,7 +6843,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; } } state.wantedKeys = keys; - const el = $("sa_mem_wanted"); + const el = $2("sa_mem_wanted"); if (el) { el.textContent = state.wanted.count ? `\u041E\u0447\u0435\u0440\u0435\u0434\u044C wanted: ${state.wanted.count} (\u0441\u043A\u0430\u0447\u0430\u0435\u0442\u0441\u044F \u043D\u0430 \u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0435\u043C up)` : "\u041E\u0447\u0435\u0440\u0435\u0434\u044C wanted: \u043F\u0443\u0441\u0442\u043E"; el.title = items.slice(0, 12).map((i) => `${i.kind}: ${i.title || i.url}`).join("\n"); @@ -6767,7 +6854,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }, 0, () => { - const el = $("sa_mem_wanted"); + const el = $2("sa_mem_wanted"); if (el) { el.textContent = "\u041E\u0447\u0435\u0440\u0435\u0434\u044C wanted: \u2014"; } @@ -6789,7 +6876,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; } function setOllamaHealth(level, text, title) { state.ollamaHealth = level; - const el = $("sa_ollama_health"); + const el = $2("sa_ollama_health"); if (!el) { return; } @@ -6804,7 +6891,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (typeof genericRequest !== "function") { return; } - const baseUrl = $("sa_base_url")?.value || "http://127.0.0.1:11434"; + const baseUrl = $2("sa_base_url")?.value || "http://127.0.0.1:11434"; genericRequest( "AssistentListModels", { baseUrl }, @@ -6826,7 +6913,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; ); } function setCardStatus(msg) { - const el = $("sa_card_status"); + const el = $2("sa_card_status"); if (el) { el.textContent = msg || ""; } @@ -6836,12 +6923,15 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; state.view = "cards"; } else if (view === "settings") { state.view = "settings"; + } else if (view === "train") { + state.view = "train"; } else { state.view = "chat"; } - const chat = $("sa_view_chat"); - const cards = $("sa_view_cards"); - const settings = $("sa_view_settings"); + const chat = $2("sa_view_chat"); + const cards = $2("sa_view_cards"); + const settings = $2("sa_view_settings"); + const train = $2("sa_view_train"); if (chat) { chat.hidden = state.view !== "chat"; } @@ -6851,18 +6941,25 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (settings) { settings.hidden = state.view !== "settings"; } - $("sa_tab_chat")?.classList.toggle("sa-subtab-active", state.view === "chat"); - $("sa_tab_cards")?.classList.toggle("sa-subtab-active", state.view === "cards"); - $("sa_tab_settings")?.classList.toggle("sa-subtab-active", state.view === "settings"); - $("sa_btn_settings")?.classList.toggle("sa-subtab-active", state.view === "settings"); - $("sa_tab_chat")?.setAttribute("aria-selected", state.view === "chat" ? "true" : "false"); - $("sa_tab_cards")?.setAttribute("aria-selected", state.view === "cards" ? "true" : "false"); - $("sa_tab_settings")?.setAttribute("aria-selected", state.view === "settings" ? "true" : "false"); + if (train) { + train.hidden = state.view !== "train"; + } + const tabActive = (id, on) => { + $2(id)?.classList.toggle("sa-subtab-active", on); + $2(id)?.classList.toggle("sa-app-tab-active", on); + $2(id)?.setAttribute("aria-selected", on ? "true" : "false"); + }; + tabActive("sa_tab_chat", state.view === "chat"); + tabActive("sa_tab_cards", state.view === "cards"); + tabActive("sa_tab_settings", state.view === "settings"); + tabActive("sa_tab_train", state.view === "train"); saveSettings(); if (state.view === "cards") { renderCardsList(); } else if (state.view === "settings") { setSettingsTab(state.settingsTab || "behavior"); + } else if (state.view === "train") { + window.SA?.training?.render?.(); } else if ((state.llmParked || state.expectColdLoad) && !state.generating) { warmLlm({ force: true }); } @@ -6945,7 +7042,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; await Promise.all(keys.map((k) => prefetchCard(k.kind, k.name))); } function cardsCatalog() { - const kind = $("sa_cards_kind")?.value || "all"; + const kind = $2("sa_cards_kind")?.value || "all"; const inv = state.inventory || {}; const rows = []; if (kind === "all" || kind === "checkpoint") { @@ -6978,7 +7075,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; return rows; } function renderCardsList() { - const root = $("sa_cards_list"); + const root = $2("sa_cards_list"); if (!root) { return; } @@ -6995,7 +7092,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (state.cardsSelection && state.cardsSelection.kind === row.kind && state.cardsSelection.name === row.name) { btn.classList.add("sa-selected"); } - const thumb = row.preview_url ? `` : '
'; + const thumb = row.preview_url ? `` : '
'; const metaBits = []; metaBits.push(row.has_card ? "card \u2713" : "\u043D\u0435\u0442 card"); if (row.has_sidecar) { @@ -7008,7 +7105,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (row.trigger) { metaBits.push(String(row.trigger).slice(0, 40)); } - btn.innerHTML = `${thumb}
${escapeHtml(row.kind)}
${escapeHtml(row.title || row.name)}
${escapeHtml(metaBits.join(" \xB7 "))}
`; + btn.innerHTML = `${thumb}
${escapeHtml2(row.kind)}
${escapeHtml2(row.title || row.name)}
${escapeHtml2(metaBits.join(" \xB7 "))}
`; btn.addEventListener("click", (e) => { if (e.target?.closest?.("[data-chat]")) { e.preventDefault(); @@ -7029,22 +7126,22 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; setView("chat"); const kind = row.kind === "checkpoint" ? "checkpoint" : "LoRA"; const triggers = row.trigger ? ` Triggers: ${row.trigger}.` : ""; - if ($("sa_input")) { - $("sa_input").value = `\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439 ${kind} \xAB${row.name}\xBB.${triggers} \u0423\u0447\u0442\u0438 \u043A\u0430\u0440\u0442\u043E\u0447\u043A\u0443/triggers \u0438 \u043F\u0440\u0435\u0434\u043B\u043E\u0436\u0438 \u043F\u0430\u0442\u0447.`; - $("sa_input").focus(); + if ($2("sa_input")) { + $2("sa_input").value = `\u0418\u0441\u043F\u043E\u043B\u044C\u0437\u0443\u0439 ${kind} \xAB${row.name}\xBB.${triggers} \u0423\u0447\u0442\u0438 \u043A\u0430\u0440\u0442\u043E\u0447\u043A\u0443/triggers \u0438 \u043F\u0440\u0435\u0434\u043B\u043E\u0436\u0438 \u043F\u0430\u0442\u0447.`; + $2("sa_input").focus(); } setStatus(`\u0412 \u0447\u0430\u0442 \u2192 ${row.name}`); } function wireCardForm() { const sync = () => { - if ($("sa_card_show_json")?.checked) { + if ($2("sa_card_show_json")?.checked) { syncCardJsonFromForm(); } }; - ["sa_card_triggers", "sa_card_weight", "sa_card_when", "sa_card_avoid", "sa_card_hint", "sa_card_notes", "sa_card_url"].forEach((id) => $(id)?.addEventListener("change", sync)); - $("sa_card_show_json")?.addEventListener("change", () => { - const on = !!$("sa_card_show_json")?.checked; - const ta = $("sa_card_json"); + ["sa_card_triggers", "sa_card_weight", "sa_card_when", "sa_card_avoid", "sa_card_hint", "sa_card_notes", "sa_card_url"].forEach((id) => $2(id)?.addEventListener("change", sync)); + $2("sa_card_show_json")?.addEventListener("change", () => { + const on = !!$2("sa_card_show_json")?.checked; + const ta = $2("sa_card_json"); if (ta) { ta.hidden = !on; if (on) { @@ -7052,8 +7149,8 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; } } }); - $("sa_card_json")?.addEventListener("change", () => { - if ($("sa_card_show_json")?.checked) { + $2("sa_card_json")?.addEventListener("change", () => { + if ($2("sa_card_show_json")?.checked) { applyCardToForm(readCardDraft() || {}); } }); @@ -7061,60 +7158,60 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; function applyCardToForm(card) { card = card || {}; const triggers = Array.isArray(card.triggers) ? card.triggers.join(", ") : card.triggers || ""; - if ($("sa_card_triggers")) { - $("sa_card_triggers").value = triggers; + if ($2("sa_card_triggers")) { + $2("sa_card_triggers").value = triggers; } - if ($("sa_card_weight")) { - $("sa_card_weight").value = card.weight != null ? card.weight : state.cardsSelection?.kind === "lora" ? 0.8 : 1; + if ($2("sa_card_weight")) { + $2("sa_card_weight").value = card.weight != null ? card.weight : state.cardsSelection?.kind === "lora" ? 0.8 : 1; } - if ($("sa_card_when")) { - $("sa_card_when").value = card.when || ""; + if ($2("sa_card_when")) { + $2("sa_card_when").value = card.when || ""; } - if ($("sa_card_avoid")) { - $("sa_card_avoid").value = card.avoid || ""; + if ($2("sa_card_avoid")) { + $2("sa_card_avoid").value = card.avoid || ""; } - if ($("sa_card_hint")) { - $("sa_card_hint").value = card.prompt_hint || ""; + if ($2("sa_card_hint")) { + $2("sa_card_hint").value = card.prompt_hint || ""; } - if ($("sa_card_notes")) { - $("sa_card_notes").value = card.notes || ""; + if ($2("sa_card_notes")) { + $2("sa_card_notes").value = card.notes || ""; } - if ($("sa_card_url")) { - $("sa_card_url").value = card.civitai_url || ""; + if ($2("sa_card_url")) { + $2("sa_card_url").value = card.civitai_url || ""; } - if ($("sa_card_json")) { - $("sa_card_json").value = JSON.stringify(card, null, 2); + if ($2("sa_card_json")) { + $2("sa_card_json").value = JSON.stringify(card, null, 2); } } function syncCardJsonFromForm() { const sel = state.cardsSelection || {}; let base = {}; try { - base = JSON.parse($("sa_card_json")?.value || "{}"); + base = JSON.parse($2("sa_card_json")?.value || "{}"); } catch (e) { base = {}; } - const triggers = String($("sa_card_triggers")?.value || "").split(/[,;]/).map((s) => s.trim()).filter(Boolean); + const triggers = String($2("sa_card_triggers")?.value || "").split(/[,;]/).map((s) => s.trim()).filter(Boolean); const card = { ...base, kind: sel.kind || base.kind || "lora", name: sel.name || base.name || "", triggers, - weight: parseFloat($("sa_card_weight")?.value || "0.8") || 0.8, - when: $("sa_card_when")?.value || "", - avoid: $("sa_card_avoid")?.value || "", - prompt_hint: $("sa_card_hint")?.value || "", - notes: $("sa_card_notes")?.value || "", - civitai_url: $("sa_card_url")?.value || "", + weight: parseFloat($2("sa_card_weight")?.value || "0.8") || 0.8, + when: $2("sa_card_when")?.value || "", + avoid: $2("sa_card_avoid")?.value || "", + prompt_hint: $2("sa_card_hint")?.value || "", + notes: $2("sa_card_notes")?.value || "", + civitai_url: $2("sa_card_url")?.value || "", version_id: base.version_id != null ? base.version_id : null }; - if ($("sa_card_json")) { - $("sa_card_json").value = JSON.stringify(card, null, 2); + if ($2("sa_card_json")) { + $2("sa_card_json").value = JSON.stringify(card, null, 2); } return card; } function renderCardPreviews(urls) { - const root = $("sa_card_previews"); + const root = $2("sa_card_previews"); if (!root) { return; } @@ -7232,7 +7329,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; ...data.example_urls || [] ]; renderCardPreviews(urls); - const badge = $("sa_card_badge"); + const badge = $2("sa_card_badge"); if (badge) { badge.hidden = false; badge.textContent = data.has_card ? "card \u2713" : data.has_sidecar || data.fetched ? "meta \u2713" : "\u043D\u0435\u0442 \u043C\u0435\u0442\u044B"; @@ -7242,10 +7339,10 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; function selectCardModel(row) { state.cardsSelection = row; renderCardsList(); - if ($("sa_card_title")) { - $("sa_card_title").textContent = row.title || row.name; + if ($2("sa_card_title")) { + $2("sa_card_title").textContent = row.title || row.name; } - const badge = $("sa_card_badge"); + const badge = $2("sa_card_badge"); if (badge) { badge.hidden = false; badge.textContent = "\u2026"; @@ -7282,8 +7379,8 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; } function readCardDraft() { const fromForm = syncCardJsonFromForm(); - if ($("sa_card_show_json")?.checked) { - const raw = $("sa_card_json")?.value || ""; + if ($2("sa_card_show_json")?.checked) { + const raw = $2("sa_card_json")?.value || ""; try { return JSON.parse(raw); } catch (e) { @@ -7355,7 +7452,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; return base.replace(/\.safetensors$/i, "").slice(0, 28); } function renderLoraChips() { - const root = $("sa_lora_chips"); + const root = $2("sa_lora_chips"); if (!root) { return; } @@ -7458,7 +7555,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }; filter.addEventListener("input", draw); draw(); - const composer = $("sa_composer") || document.body; + const composer = $2("sa_composer") || document.body; composer.style.position = composer.style.position || "relative"; composer.appendChild(picker); const onDoc = (ev) => { @@ -7542,8 +7639,8 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (fromCards) { const card = extractCardJson(reply); if (card) { - if ($("sa_card_json")) { - $("sa_card_json").value = JSON.stringify(card, null, 2); + if ($2("sa_card_json")) { + $2("sa_card_json").value = JSON.stringify(card, null, 2); } if (opts.fromDownload || opts.cardTarget) { const kind = card.kind || opts.cardTarget?.kind || "lora"; @@ -7614,7 +7711,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (Array.isArray(effective?.actions) && effective.actions.map(String).includes("interrupt")) { doInterruptNow(); } - if (civitaiResults && civitaiResults.length && $("sa_auto_download")?.checked) { + if (civitaiResults && civitaiResults.length && $2("sa_auto_download")?.checked) { const pick = civitaiResults.find((r) => !r.already_installed && r.download_url && r.krea_likely) || civitaiResults.find((r) => !r.already_installed && r.download_url); if (pick) { downloadCivitaiLoRA(pick, null); @@ -7657,7 +7754,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }); return; } - const doApply = !!(effective && (intent.generate || $("sa_auto_apply")?.checked)); + const doApply = !!(effective && (intent.generate || $2("sa_auto_apply")?.checked)); if (doApply) { if (intent.generate) { startBusyUi("silent_gen"); @@ -7696,13 +7793,13 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; await applyPatch(withActions, "all"); state.lastUserParamIntent = prevIntent; setStatus(note || "Applied"); - if ($("sa_auto_generate")?.checked) { + if ($2("sa_auto_generate")?.checked) { await runGenerateFromPatch(withActions); } syncChipHighlight(); } function syncChipHighlight() { - const bar = $("sa_chips"); + const bar = $2("sa_chips"); if (!bar) { return; } @@ -7718,7 +7815,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }); } function appendSystemNote(text) { - const box = $("sa_messages"); + const box = $2("sa_messages"); if (!box) { return; } @@ -7747,10 +7844,10 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }).join(", "); } function buildDebugSummary() { - const persona = $("sa_persona")?.value || "neutral"; - const pack = $("sa_pack")?.value || defaultPackId(); - const chatModel = $("sa_model")?.value || "\u2014"; - const embed = $("sa_embed_model")?.value || state.preferredEmbed || "\u2014"; + const persona = $2("sa_persona")?.value || "neutral"; + const pack = $2("sa_pack")?.value || defaultPackId(); + const chatModel = $2("sa_model")?.value || "\u2014"; + const embed = $2("sa_embed_model")?.value || state.preferredEmbed || "\u2014"; const profile = detectKreaProfileName(); const defaults = mergedGenerationDefaults(profile); const session = state.sessionExact || {}; @@ -7785,7 +7882,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; `persona=${persona} \xB7 pack=${pack}`, `chat=${chatModel} \xB7 embed=${embed}`, `skills=${(state.enabledSkills || []).join(",") || "\u2014"}`, - `auto: apply=${!!$("sa_auto_apply")?.checked} gen=${!!$("sa_auto_generate")?.checked} vision=${!!$("sa_auto_vision")?.checked} critique=${!!$("sa_auto_critique")?.checked}`, + `auto: apply=${!!$2("sa_auto_apply")?.checked} gen=${!!$2("sa_auto_generate")?.checked} vision=${!!$2("sa_auto_vision")?.checked} critique=${!!$2("sa_auto_critique")?.checked}`, "", "Live SwarmUI:", ` ckpt=${ctx.checkpoint?.name || "\u2014"} \xB7 krea_profile=${ctx.krea_profile || profile}`, @@ -7909,8 +8006,8 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; } slot.attach = true; renderBoard(); - if ($("sa_input")) { - $("sa_input").value = `Look at ${id} and describe what you see.`; + if ($2("sa_input")) { + $2("sa_input").value = `Look at ${id} and describe what you see.`; } setPackValue("critique_image", { flash: true }); await sendChat({ forceSlotIds: [id], skipAutoPack: true }); @@ -7982,7 +8079,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; if (!setPackValue(arg, { flash: true, user: true })) { setStatus("Pack: write|ordinary|critique|compose|params|inpaint|describe|card|persona"); } else { - setStatus(`Pack \u2192 ${$("sa_pack")?.value}`); + setStatus(`Pack \u2192 ${$2("sa_pack")?.value}`); } return true; } @@ -7999,8 +8096,8 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; setStatus("/civitai "); return true; } - if ($("sa_input")) { - $("sa_input").value = `Find a Krea 2 LoRA for: ${arg}`; + if ($2("sa_input")) { + $2("sa_input").value = `Find a Krea 2 LoRA for: ${arg}`; } setPackValue(defaultPackId(), { flash: true }); await sendChat({ @@ -8020,7 +8117,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; }); return true; } - const fromId = sub === "clone" && rest ? rest.split(/\s+/)[0] : $("sa_persona")?.value || "neutral"; + const fromId = sub === "clone" && rest ? rest.split(/\s+/)[0] : $2("sa_persona")?.value || "neutral"; await sendChat({ skipAutoPack: true, forcedUserText: `\u041D\u0430\u0447\u043D\u0438 \u0438\u043D\u0442\u0435\u0440\u0432\u044C\u044E author_persona: \u043A\u043B\u043E\u043D \u0441 \u0438\u0441\u0442\u043E\u0447\u043D\u0438\u043A\u0430 \xAB${fromId}\xBB. \u0421\u043F\u0440\u0430\u0448\u0438\u0432\u0430\u0439 \u043F\u043E \u043F\u043E\u043B\u043A\u0430\u043C \u0433\u0440\u0443\u043F\u043F\u0430\u043C\u0438. \u041D\u0435 \u043F\u0438\u0448\u0438 \u043D\u0430 \u0434\u0438\u0441\u043A, \u043F\u043E\u043A\u0430 \u043C\u0430\u043B\u043E \u043E\u0442\u0432\u0435\u0442\u043E\u0432. \u041D\u0435 \u0443\u0434\u0430\u043B\u044F\u0439 \u043B\u0438\u0447\u043D\u043E\u0441\u0442\u0438.` @@ -8066,8 +8163,8 @@ ${HELP_TEXT}`); s.attach = true; } renderBoard(); - if ($("sa_input")) { - $("sa_input").value = `Look at board slots: ${need.map((s) => s.id).join(", ")}. Continue using these images.`; + if ($2("sa_input")) { + $2("sa_input").value = `Look at board slots: ${need.map((s) => s.id).join(", ")}. Continue using these images.`; } setStatus(`Vision hop \u2190 ${need.map((s) => s.label).join(", ")}`); await sendChat({ fromVisionHop: true, forceSlotIds: need.map((s) => s.id) }); @@ -8077,7 +8174,11 @@ ${HELP_TEXT}`); if ((state.busy || state.generating) && !isContinuationTurn(opts)) { return; } - const rawInput = ($("sa_input")?.value || "").trim(); + if (isTrainingLocked() && !isContinuationTurn(opts)) { + setStatus("\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430 \u2014 \u0447\u0430\u0442 \u0437\u0430\u0431\u043B\u043E\u043A\u0438\u0440\u043E\u0432\u0430\u043D"); + return; + } + const rawInput = ($2("sa_input")?.value || "").trim(); const text = (opts.forcedUserText || rawInput).trim(); if (!text) { return; @@ -8089,8 +8190,8 @@ ${HELP_TEXT}`); } if (!isMachineTurn(opts) && !opts.skipSlash) { if (rawInput.startsWith("/")) { - if ($("sa_input")) { - $("sa_input").value = ""; + if ($2("sa_input")) { + $2("sa_input").value = ""; } const handled = await handleSlashCommand(rawInput); if (handled) { @@ -8101,8 +8202,8 @@ ${HELP_TEXT}`); if (!isMachineTurn(opts) && isSameButAspectRequest(text)) { const aspect = parseAspectFromUserText(text); if (aspect) { - if ($("sa_input")) { - $("sa_input").value = ""; + if ($2("sa_input")) { + $2("sa_input").value = ""; } appendMessage("user", text); state.history.push({ role: "user", content: text }); @@ -8133,9 +8234,9 @@ ${HELP_TEXT}`); if (!opts.fromDebug && (opts.fromCards || state.view === "cards")) { setPackValue("catalog_card", { flash: false }); } - const pack = opts.fromDebug ? "debug_explain" : $("sa_pack")?.value || defaultPackId(); - const persona = $("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral"; - const model = $("sa_model")?.value; + const pack = opts.fromDebug ? "debug_explain" : $2("sa_pack")?.value || defaultPackId(); + const persona = $2("sa_persona")?.value || localStorage.getItem(LS_PERSONA) || "neutral"; + const model = $2("sa_model")?.value; if (!model) { setStatus("\u0412\u044B\u0431\u0435\u0440\u0438 \u043C\u043E\u0434\u0435\u043B\u044C Ollama \u0432 \u2699"); refreshModels(); @@ -8211,8 +8312,8 @@ ${HELP_TEXT}`); state.pendingPersonaNote = null; } appendMessage("user", opts.historyUserText || text); - if ($("sa_input")) { - $("sa_input").value = ""; + if ($2("sa_input")) { + $2("sa_input").value = ""; } persistHistory(); } else { @@ -8255,7 +8356,7 @@ ${HELP_TEXT}`); messages[messages.length - 1].images = images; } startBusyUi("thinking"); - const baseUrl = $("sa_base_url")?.value || "http://127.0.0.1:11434"; + const baseUrl = $2("sa_base_url")?.value || "http://127.0.0.1:11434"; const payload = { baseUrl, model, @@ -8265,7 +8366,7 @@ ${HELP_TEXT}`); messages, context_json: JSON.stringify(context), skills: opts.fromDebug ? [] : state.enabledSkills || [], - embed_model: $("sa_embed_model")?.value || state.preferredEmbed || "" + embed_model: $2("sa_embed_model")?.value || state.preferredEmbed || "" }; const finishOk = async (reply, civitaiResults, meta = {}) => { if (chatEpoch !== state.chatEpoch) { @@ -8418,8 +8519,8 @@ ${HELP_TEXT}`); ); } function wireDropZone() { - const board = $("sa_board"); - const layout = $("sa_layout"); + const board = $2("sa_board"); + const layout = $2("sa_layout"); layout?.addEventListener("dragover", (e) => { if (e.dataTransfer?.types?.includes("Files") || e.dataTransfer?.types?.includes("text/uri-list")) { e.preventDefault(); @@ -8500,9 +8601,9 @@ ${HELP_TEXT}`); }); } function wireSplitter() { - const splitter = $("sa_splitter"); - const layout = $("sa_layout"); - const pane = $("sa_image_pane"); + const splitter = $2("sa_splitter"); + const layout = $2("sa_layout"); + const pane = $2("sa_image_pane"); if (!splitter || !layout || !pane) { return; } @@ -8553,7 +8654,7 @@ ${HELP_TEXT}`); note: "Image sent to Assistent", preferSelected: false }); - const pack = $("sa_pack"); + const pack = $2("sa_pack"); if (pack && (pack.value === "ordinary" || pack.value === "write_prompt")) { pack.value = "critique_image"; saveSettings(); @@ -8587,7 +8688,7 @@ ${HELP_TEXT}`); refreshWantedQueue(); } function wire() { - if (!$("swarm_assistent_root")) { + if (!$2("swarm_assistent_root")) { return; } if (typeof genericRequest !== "function") { @@ -8608,28 +8709,30 @@ ${HELP_TEXT}`); refreshImagePreview(); } bootstrapPersisted(); + setChatsPanelOpen(!!state.chatsDrawerOpen); wireDropZone(); wireSplitter(); registerSendButton(); wireSlashInput(); wireCardForm(); - $("sa_btn_new_chat")?.addEventListener("click", () => startNewChat({ saveCurrent: true })); - $("sa_btn_chats")?.addEventListener("click", (e) => { + $2("sa_btn_new_chat")?.addEventListener("click", () => startNewChat({ saveCurrent: true })); + $2("sa_btn_chats")?.addEventListener("click", (e) => { e.stopPropagation(); setChatsPanelOpen(!state.chatsPanelOpen); }); - $("sa_session_label")?.addEventListener("click", (e) => { + $2("sa_btn_chats_close")?.addEventListener("click", () => setChatsPanelOpen(false)); + $2("sa_session_label")?.addEventListener("click", (e) => { e.stopPropagation(); setChatsPanelOpen(!state.chatsPanelOpen); }); - $("sa_session_label")?.addEventListener("keydown", (e) => { + $2("sa_session_label")?.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setChatsPanelOpen(!state.chatsPanelOpen); } }); - $("sa_chats_panel")?.addEventListener("click", (e) => e.stopPropagation()); - $("sa_chats_list")?.addEventListener("click", (e) => { + $2("sa_chats_panel")?.addEventListener("click", (e) => e.stopPropagation()); + $2("sa_chats_list")?.addEventListener("click", (e) => { const row = e.target.closest(".sa-chat-row"); if (!row) { return; @@ -8647,8 +8750,8 @@ ${HELP_TEXT}`); } }); let chatsSearchTimer = null; - $("sa_chats_search")?.addEventListener("input", () => { - const q = ($("sa_chats_search")?.value || "").trim(); + $2("sa_chats_search")?.addEventListener("input", () => { + const q = ($2("sa_chats_search")?.value || "").trim(); state.chatsQuery = q; if (!q) { state.chatsSearchHits = null; @@ -8669,90 +8772,83 @@ ${HELP_TEXT}`); } }, 220); }); - $("sa_tab_chat")?.addEventListener("click", () => setView("chat")); - $("sa_tab_cards")?.addEventListener("click", () => setView("cards")); - $("sa_tab_settings")?.addEventListener("click", () => openSettings(state.settingsTab || "behavior")); - $("sa_board_tab_gen")?.addEventListener("click", () => setBoardTab("generate")); - $("sa_board_tab_refs")?.addEventListener("click", () => setBoardTab("refs")); - $("sa_persona")?.addEventListener("change", onPersonaChanged); - $("sa_persona_delete")?.addEventListener("click", () => deleteCurrentOverlayPersona()); - $("sa_cards_kind")?.addEventListener("change", renderCardsList); - $("sa_btn_cards_refresh")?.addEventListener("click", () => refreshInventory(() => renderCardsList(), { rescan: true })); - $("sa_btn_card_meta")?.addEventListener("click", () => fetchCardMetaLive()); - $("sa_btn_card_generate")?.addEventListener("click", () => generateCardWithAssistent()); - $("sa_btn_card_save")?.addEventListener("click", () => saveCurrentCard()); - $("sa_btn_card_wanted")?.addEventListener("click", () => enqueueWantedOnly()); - $("sa_btn_settings")?.addEventListener("click", () => { - if (state.view === "settings") { - closeSettings(); - } else { - openSettings(state.settingsTab || "behavior"); - } - }); - $("sa_settings_close")?.addEventListener("click", () => closeSettings()); + $2("sa_tab_chat")?.addEventListener("click", () => setView("chat")); + $2("sa_tab_cards")?.addEventListener("click", () => setView("cards")); + $2("sa_tab_train")?.addEventListener("click", () => setView("train")); + $2("sa_tab_settings")?.addEventListener("click", () => openSettings(state.settingsTab || "behavior")); + $2("sa_board_tab_gen")?.addEventListener("click", () => setBoardTab("generate")); + $2("sa_board_tab_refs")?.addEventListener("click", () => setBoardTab("refs")); + $2("sa_persona")?.addEventListener("change", onPersonaChanged); + $2("sa_persona_delete")?.addEventListener("click", () => deleteCurrentOverlayPersona()); + $2("sa_cards_kind")?.addEventListener("change", renderCardsList); + $2("sa_btn_cards_refresh")?.addEventListener("click", () => refreshInventory(() => renderCardsList(), { rescan: true })); + $2("sa_btn_card_meta")?.addEventListener("click", () => fetchCardMetaLive()); + $2("sa_btn_card_generate")?.addEventListener("click", () => generateCardWithAssistent()); + $2("sa_btn_card_save")?.addEventListener("click", () => saveCurrentCard()); + $2("sa_btn_card_wanted")?.addEventListener("click", () => enqueueWantedOnly()); document.querySelectorAll("#sa_settings .sa-stab").forEach((btn) => { btn.addEventListener("click", () => setSettingsTab(btn.getAttribute("data-stab"))); }); - $("sa_btn_mem_refresh")?.addEventListener("click", () => { + $2("sa_btn_mem_refresh")?.addEventListener("click", () => { refreshMemoryList(); refreshWantedQueue(); }); - $("sa_mem_kind")?.addEventListener("change", renderMemoryList); - $("sa_mem_scope")?.addEventListener("change", renderMemoryList); - $("sa_mem_search")?.addEventListener("input", () => renderMemoryList()); - $("sa_btn_mem_clear_kind")?.addEventListener("click", () => { + $2("sa_mem_kind")?.addEventListener("change", renderMemoryList); + $2("sa_mem_scope")?.addEventListener("change", renderMemoryList); + $2("sa_mem_search")?.addEventListener("input", () => renderMemoryList()); + $2("sa_btn_mem_clear_kind")?.addEventListener("click", () => { const kind = memoryKindFilter(); clearCraftMemory({ kind: kind === "all" ? "" : kind, label: kind === "all" ? "\u0432\u0435\u0441\u044C \u043A\u0440\u0430\u0444\u0442 (\u0444\u0438\u043B\u044C\u0442\u0440 \u0442\u0438\u043F\u0430)" : `\u0442\u0438\u043F ${kind}` }); }); - $("sa_btn_mem_clear_persona")?.addEventListener("click", () => { - clearCraftMemory({ scope: "personal", persona: $("sa_persona")?.value || "neutral", label: "\u043A\u0440\u0430\u0444\u0442 \u044D\u0442\u043E\u0439 \u043B\u0438\u0447\u043D\u043E\u0441\u0442\u0438" }); + $2("sa_btn_mem_clear_persona")?.addEventListener("click", () => { + clearCraftMemory({ scope: "personal", persona: $2("sa_persona")?.value || "neutral", label: "\u043A\u0440\u0430\u0444\u0442 \u044D\u0442\u043E\u0439 \u043B\u0438\u0447\u043D\u043E\u0441\u0442\u0438" }); }); - $("sa_btn_mem_clear_shared")?.addEventListener("click", () => { + $2("sa_btn_mem_clear_shared")?.addEventListener("click", () => { clearCraftMemory({ scope: "shared", label: "\u043E\u0431\u0449\u0443\u044E \u043A\u0440\u0430\u0444\u0442-\u043F\u0430\u043C\u044F\u0442\u044C" }); }); - $("sa_btn_mem_clear_all")?.addEventListener("click", () => { + $2("sa_btn_mem_clear_all")?.addEventListener("click", () => { clearCraftMemory({ label: "\u0432\u0435\u0441\u044C \u043A\u0440\u0430\u0444\u0442 (non-bundled)" }); }); - $("sa_btn_prefs_refresh")?.addEventListener("click", () => refreshUserPrefs()); - $("sa_btn_pref_add_global")?.addEventListener("click", () => addUserPref("global")); - $("sa_btn_pref_add_persona")?.addEventListener("click", () => addUserPref("persona")); - $("sa_btn_prefs_clear_global")?.addEventListener("click", () => clearUserPrefs("global")); - $("sa_btn_prefs_clear_persona")?.addEventListener("click", () => clearUserPrefs("persona")); - $("sa_btn_prefs_clear_all")?.addEventListener("click", () => clearUserPrefs("all")); - $("sa_user_prefs_weight")?.addEventListener("input", () => { - const lab = $("sa_user_prefs_weight_val"); + $2("sa_btn_prefs_refresh")?.addEventListener("click", () => refreshUserPrefs()); + $2("sa_btn_pref_add_global")?.addEventListener("click", () => addUserPref("global")); + $2("sa_btn_pref_add_persona")?.addEventListener("click", () => addUserPref("persona")); + $2("sa_btn_prefs_clear_global")?.addEventListener("click", () => clearUserPrefs("global")); + $2("sa_btn_prefs_clear_persona")?.addEventListener("click", () => clearUserPrefs("persona")); + $2("sa_btn_prefs_clear_all")?.addEventListener("click", () => clearUserPrefs("all")); + $2("sa_user_prefs_weight")?.addEventListener("input", () => { + const lab = $2("sa_user_prefs_weight_val"); if (lab) { - lab.textContent = Number($("sa_user_prefs_weight").value).toFixed(1); + lab.textContent = Number($2("sa_user_prefs_weight").value).toFixed(1); } }); - $("sa_user_prefs_weight")?.addEventListener("change", () => saveKnobs()); - $("sa_memory_top_k")?.addEventListener("change", () => saveKnobs()); - $("sa_btn_knobs_save")?.addEventListener("click", () => saveKnobs()); - $("sa_btn_reset_ui")?.addEventListener("click", () => resetUiState()); - $("sa_btn_persona_export")?.addEventListener("click", () => exportSelectedPersona()); - $("sa_btn_persona_import")?.addEventListener("click", () => $("sa_persona_import_file")?.click()); - $("sa_persona_import_file")?.addEventListener("change", (e) => { + $2("sa_user_prefs_weight")?.addEventListener("change", () => saveKnobs()); + $2("sa_memory_top_k")?.addEventListener("change", () => saveKnobs()); + $2("sa_btn_knobs_save")?.addEventListener("click", () => saveKnobs()); + $2("sa_btn_reset_ui")?.addEventListener("click", () => resetUiState()); + $2("sa_btn_persona_export")?.addEventListener("click", () => exportSelectedPersona()); + $2("sa_btn_persona_import")?.addEventListener("click", () => $2("sa_persona_import_file")?.click()); + $2("sa_persona_import_file")?.addEventListener("change", (e) => { const file = e.target?.files?.[0]; if (file) { importPersonaFile(file); } e.target.value = ""; }); - $("sa_btn_persona_clone")?.addEventListener("click", () => cloneSelectedPersona()); - $("sa_btn_persona_delete_panel")?.addEventListener("click", () => deleteSelectedOverlayPersona()); - $("sa_btn_settings_health")?.addEventListener("click", () => { + $2("sa_btn_persona_clone")?.addEventListener("click", () => cloneSelectedPersona()); + $2("sa_btn_persona_delete_panel")?.addEventListener("click", () => deleteSelectedOverlayPersona()); + $2("sa_btn_settings_health")?.addEventListener("click", () => { probeOllamaHealth(); setTimeout(syncSettingsHealthLine, 400); }); - $("sa_settings_chat_model")?.addEventListener("change", () => { - const v = $("sa_settings_chat_model")?.value; - if (v && $("sa_model")) { - $("sa_model").value = v; + $2("sa_settings_chat_model")?.addEventListener("change", () => { + const v = $2("sa_settings_chat_model")?.value; + if (v && $2("sa_model")) { + $2("sa_model").value = v; saveSettings(); } }); - $("sa_btn_look_result")?.addEventListener("click", () => askLookAtResult()); - $("sa_ollama_health")?.addEventListener("click", () => probeOllamaHealth()); + $2("sa_btn_look_result")?.addEventListener("click", () => askLookAtResult()); + $2("sa_ollama_health")?.addEventListener("click", () => probeOllamaHealth()); document.addEventListener("keydown", (e) => { if (state.lightboxIndex >= 0) { if (e.key === "Escape") { @@ -8783,7 +8879,7 @@ ${HELP_TEXT}`); setChatsPanelOpen(false); closed = true; } - const slash = $("sa_slash_menu"); + const slash = $2("sa_slash_menu"); if (slash && !slash.hidden) { slash.hidden = true; closed = true; @@ -8794,23 +8890,23 @@ ${HELP_TEXT}`); } }); document.getElementById(TAB_BUTTON_ID)?.addEventListener("click", () => { - setTimeout(() => $("sa_input")?.focus(), 80); + setTimeout(() => $2("sa_input")?.focus(), 80); }); - $("sa_btn_refresh_models")?.addEventListener("click", () => { + $2("sa_btn_refresh_models")?.addEventListener("click", () => { saveSettings(); refreshModels(); probeOllamaHealth(); }); - $("sa_btn_refresh_inventory")?.addEventListener("click", () => refreshInventory(() => { + $2("sa_btn_refresh_inventory")?.addEventListener("click", () => refreshInventory(() => { renderCardsList(); renderLoraChips(); }, { rescan: true })); - $("sa_btn_add_ref")?.addEventListener("click", () => { + $2("sa_btn_add_ref")?.addEventListener("click", () => { setBoardTab("refs"); addRefSlot({ select: true }); }); - $("sa_btn_use_current")?.addEventListener("click", () => snapshotGenerateToRef()); - $("sa_btn_as_init")?.addEventListener("click", async () => { + $2("sa_btn_use_current")?.addEventListener("click", () => snapshotGenerateToRef()); + $2("sa_btn_as_init")?.addEventListener("click", async () => { closeAllMoreMenus(); const src = selectedSrc() || findCurrentGenerateSrc(); if (!src) { @@ -8818,12 +8914,12 @@ ${HELP_TEXT}`); return; } await setInitFromSrc(src); - const pack = $("sa_pack"); + const pack = $2("sa_pack"); if (pack && (pack.value === "ordinary" || pack.value === "write_prompt")) { setPackValue("inpaint_edit", { flash: true }); } }); - $("sa_btn_as_mask")?.addEventListener("click", async () => { + $2("sa_btn_as_mask")?.addEventListener("click", async () => { closeAllMoreMenus(); const src = selectedSrc(); if (!src) { @@ -8833,43 +8929,43 @@ ${HELP_TEXT}`); await setMaskFromSrc(src); setPackValue("inpaint_edit", { flash: true }); }); - $("sa_btn_clear_init")?.addEventListener("click", () => { + $2("sa_btn_clear_init")?.addEventListener("click", () => { if (window.confirm("\u0421\u0431\u0440\u043E\u0441\u0438\u0442\u044C Init \u0438 Mask?")) { clearInitAndMask(); } closeAllMoreMenus(); }); - $("sa_btn_clear_image")?.addEventListener("click", () => clearSlot(state.selectedSlotId)); - $("sa_btn_board_more")?.addEventListener("click", (e) => { + $2("sa_btn_clear_image")?.addEventListener("click", () => clearSlot(state.selectedSlotId)); + $2("sa_btn_board_more")?.addEventListener("click", (e) => { e.stopPropagation(); toggleMoreMenu("sa_board_more_menu", "sa_btn_board_more"); }); - $("sa_btn_send")?.addEventListener("click", () => sendChat()); - $("sa_btn_build_gen")?.addEventListener("click", () => buildCurrentAndGenerate()); - $("sa_btn_interrupt")?.addEventListener("click", () => { + $2("sa_btn_send")?.addEventListener("click", () => sendChat()); + $2("sa_btn_build_gen")?.addEventListener("click", () => buildCurrentAndGenerate()); + $2("sa_btn_interrupt")?.addEventListener("click", () => { doInterruptNow(); clearInFlightUi({ status: "\u041F\u0440\u0435\u0440\u0432\u0430\u043D\u043E" }); }); - $("sa_btn_clear")?.addEventListener("click", () => { + $2("sa_btn_clear")?.addEventListener("click", () => { if (window.confirm("\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C \u0432\u0435\u0441\u044C \u0447\u0430\u0442 Assistent?")) { clearChatHistory(); } }); - $("sa_btn_clear_more")?.addEventListener("click", (e) => { + $2("sa_btn_clear_more")?.addEventListener("click", (e) => { e.stopPropagation(); toggleMoreMenu("sa_clear_more_menu", "sa_btn_clear_more"); }); - $("sa_btn_clear_confirm")?.addEventListener("click", () => { + $2("sa_btn_clear_confirm")?.addEventListener("click", () => { closeAllMoreMenus(); if (window.confirm("\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C \u0432\u0435\u0441\u044C \u0447\u0430\u0442 Assistent?")) { clearChatHistory(); } }); - $("sa_btn_clear_patches")?.addEventListener("click", () => { + $2("sa_btn_clear_patches")?.addEventListener("click", () => { closeAllMoreMenus(); clearPatchBlocksOnly(); }); - $("sa_btn_card_to_chat")?.addEventListener("click", () => { + $2("sa_btn_card_to_chat")?.addEventListener("click", () => { if (state.cardsSelection) { sendCardToChat(state.cardsSelection); } else { @@ -8882,26 +8978,26 @@ ${HELP_TEXT}`); } closeAllMoreMenus(); }); - $("sa_board_more_menu")?.addEventListener("click", (e) => e.stopPropagation()); - $("sa_clear_more_menu")?.addEventListener("click", (e) => e.stopPropagation()); - $("sa_base_url")?.addEventListener("change", saveSettings); - $("sa_model")?.addEventListener("change", () => { - const v = $("sa_model")?.value; - if (v && $("sa_settings_chat_model")) { - $("sa_settings_chat_model").value = v; + $2("sa_board_more_menu")?.addEventListener("click", (e) => e.stopPropagation()); + $2("sa_clear_more_menu")?.addEventListener("click", (e) => e.stopPropagation()); + $2("sa_base_url")?.addEventListener("change", saveSettings); + $2("sa_model")?.addEventListener("change", () => { + const v = $2("sa_model")?.value; + if (v && $2("sa_settings_chat_model")) { + $2("sa_settings_chat_model").value = v; } saveSettings(); }); - $("sa_embed_model")?.addEventListener("change", () => { - state.preferredEmbed = $("sa_embed_model")?.value || ""; + $2("sa_embed_model")?.addEventListener("change", () => { + state.preferredEmbed = $2("sa_embed_model")?.value || ""; saveSettings(); }); - $("sa_pack")?.addEventListener("change", () => { + $2("sa_pack")?.addEventListener("change", () => { state.packUserTouched = true; saveSettings(); syncModeBadge(); }); - $("sa_chips")?.addEventListener("click", async (e) => { + $2("sa_chips")?.addEventListener("click", async (e) => { const btn = e.target.closest(".sa-chip"); if (!btn || state.busy || state.generating) { return; @@ -8927,7 +9023,7 @@ ${HELP_TEXT}`); } renderLoraChips(); }); - $("sa_auto_vision")?.addEventListener("change", () => { + $2("sa_auto_vision")?.addEventListener("change", () => { saveSettings(); const gen = generateSlot(); if (gen) { @@ -8935,11 +9031,11 @@ ${HELP_TEXT}`); renderBoard(); } }); - $("sa_auto_apply")?.addEventListener("change", saveSettings); - $("sa_auto_generate")?.addEventListener("change", saveSettings); - $("sa_auto_critique")?.addEventListener("change", saveSettings); - $("sa_auto_download")?.addEventListener("change", saveSettings); - $("sa_park_llm")?.addEventListener("change", saveSettings); + $2("sa_auto_apply")?.addEventListener("change", saveSettings); + $2("sa_auto_generate")?.addEventListener("change", saveSettings); + $2("sa_auto_critique")?.addEventListener("change", saveSettings); + $2("sa_auto_download")?.addEventListener("change", saveSettings); + $2("sa_park_llm")?.addEventListener("change", saveSettings); syncChipHighlight(); setInterval(syncChipHighlight, 2500); setInterval(renderLoraChips, 4e3); @@ -8976,6 +9072,15 @@ ${HELP_TEXT}`); refreshInventory(null, { rescan: inventoryIsStale(tabOn ? 45e3 : 12e4) }); } }, 3e4); + window.SA = window.SA || {}; + window.SA.app = { + getState: () => state, + setTrainingLock(on) { + state.trainingLock = !!on; + }, + refreshModels: () => refreshModels(), + setStatus: (msg) => setStatus(msg) + }; window.swarmAssistent = { setImageFromSrc, putImageOnBoard, @@ -9000,14 +9105,14 @@ ${HELP_TEXT}`); }; } function wireSlashInput() { - const input = $("sa_input"); + const input = $2("sa_input"); if (!input || input.dataset.saSlashWired) { return; } input.dataset.saSlashWired = "1"; input.addEventListener("input", () => updateSlashMenuFromInput()); input.addEventListener("keydown", (e) => { - const menu = $("sa_slash_menu"); + const menu = $2("sa_slash_menu"); const open = menu && !menu.hidden; if (open) { const items = slashMatches(input.value.split(/\s/)[0] || ""); @@ -9051,6 +9156,517 @@ ${HELP_TEXT}`); } })(); + // src/training.js + var $ = (id) => document.getElementById(id); + function escapeHtml(s) { + return String(s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function attachTraining(SA2) { + const state = { + ttab: "dataset", + samples: [], + hfResults: [], + hfSelected: null, + hfCheck: null, + trainWs: null, + polling: null, + agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 }, + agentLinked: 0 + }; + function setAgentHeardStats(linked) { + const el = $("sa_agent_heard_stats"); + if (el) { + el.textContent = `\u041F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u043E: ${linked ?? state.agentLinked ?? "\u2014"}`; + } + } + async function loadAgentHeardSettings() { + try { + const data = await SA2.request("AssistentGetDatasetAgentSettings", {}); + const s = data?.settings || {}; + state.agentSettings = { + enabled: s.enabled !== false, + auto_link_on_approve: s.auto_link_on_approve !== false, + heard_quota: s.heard_quota ?? 3 + }; + state.agentLinked = data?.linked ?? 0; + if ($("sa_agent_heard_enabled")) $("sa_agent_heard_enabled").checked = state.agentSettings.enabled; + if ($("sa_agent_auto_link")) $("sa_agent_auto_link").checked = state.agentSettings.auto_link_on_approve; + if ($("sa_agent_heard_quota")) $("sa_agent_heard_quota").value = String(state.agentSettings.heard_quota); + setAgentHeardStats(state.agentLinked); + } catch (e) { + console.warn("loadAgentHeardSettings", e); + } + } + async function saveAgentHeardSettings() { + const settings = { + enabled: !!$("sa_agent_heard_enabled")?.checked, + auto_link_on_approve: !!$("sa_agent_auto_link")?.checked, + heard_quota: Math.max(0, Math.min(8, parseInt($("sa_agent_heard_quota")?.value, 10) || 3)) + }; + try { + const data = await SA2.request("AssistentSaveDatasetAgentSettings", { settings }); + state.agentSettings = data?.settings || settings; + setTrainStatus("\u041D\u0430\u0441\u0442\u0440\u043E\u0439\u043A\u0438 \xAB\u0443\u0441\u043B\u044B\u0448\u0430\u043D\u043D\u043E\u0433\u043E\xBB \u0441\u043E\u0445\u0440\u0430\u043D\u0435\u043D\u044B"); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + async function syncAllToAgent() { + setTrainStatus("\u041F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435 \u043A \u0430\u0433\u0435\u043D\u0442\u0443\u2026"); + try { + await saveAgentHeardSettings(); + const data = await SA2.request("AssistentSyncDatasetToAgent", { approved_only: true, relink: false }); + state.agentLinked = data?.total_linked ?? state.agentLinked; + setAgentHeardStats(state.agentLinked); + setTrainStatus(`\u041F\u043E\u0434\u043A\u043B\u044E\u0447\u0435\u043D\u043E: +${data?.linked_now ?? 0}, \u0432\u0441\u0435\u0433\u043E ${data?.total_linked ?? "\u2014"}`); + await refreshSamples(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + function setTrainStatus(msg) { + const el = $("sa_train_status"); + if (el) el.textContent = msg || ""; + } + function setTrainingTab(id) { + state.ttab = id || "dataset"; + document.querySelectorAll("#sa_training .sa-ttab").forEach((btn) => { + const on = btn.getAttribute("data-ttab") === state.ttab; + btn.classList.toggle("sa-ttab-active", on); + btn.setAttribute("aria-selected", on ? "true" : "false"); + }); + document.querySelectorAll("#sa_training .sa-tpane").forEach((pane) => { + pane.hidden = pane.getAttribute("data-tpane") !== state.ttab; + }); + if (state.ttab === "dataset") { + refreshSamples(); + loadAgentHeardSettings(); + } + if (state.ttab === "train") syncModelfileModels(); + if (state.ttab === "models") refreshTrainModels(); + } + async function refreshSamples() { + try { + const status = $("sa_train_filter_status")?.value || "all"; + const persona = $("sa_train_filter_persona")?.value || "all"; + const data = await SA2.request("AssistentListTrainSamples", { status, persona, limit: 300 }); + state.samples = data?.samples || []; + const stats = $("sa_train_stats"); + if (stats) stats.textContent = `\u041E\u0434\u043E\u0431\u0440\u0435\u043D\u043E: ${data?.approved ?? "\u2014"} \xB7 \u0432\u0441\u0435\u0433\u043E: ${data?.total ?? "\u2014"}`; + const personaSel = $("sa_train_filter_persona"); + if (personaSel && $("sa_persona")) { + const cur = personaSel.value || "all"; + personaSel.innerHTML = ''; + for (const opt of $("sa_persona").options) { + const o = document.createElement("option"); + o.value = opt.value; + o.textContent = opt.textContent; + personaSel.appendChild(o); + } + personaSel.value = cur; + } + renderSamples(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + function renderSamples() { + const root = $("sa_train_samples"); + if (!root) return; + if (!state.samples.length) { + root.innerHTML = '
\u041D\u0435\u0442 \u043F\u0440\u0438\u043C\u0435\u0440\u043E\u0432. \u041E\u0442\u043C\u0435\u0442\u044C \u043E\u0442\u0432\u0435\u0442\u044B \u0432 \u0447\u0430\u0442\u0435 \u0438\u043B\u0438 \u0438\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u0443\u0439 \u0434\u0430\u0442\u0430\u0441\u0435\u0442.
'; + return; + } + root.innerHTML = ""; + for (const s of state.samples) { + const div = document.createElement("div"); + div.className = "sa-train-sample"; + div.dataset.id = s.id; + const msgs = s.messages || []; + const preview = msgs.map((m) => `${m.role}: ${(m.content || "").slice(0, 120)}`).join("\n"); + const linked = s.agent_linked ? " \xB7 \u{1F517} \u0430\u0433\u0435\u043D\u0442" : ""; + div.innerHTML = ` +
+ ${escapeHtml(s.status)} + ${escapeHtml(s.source)} \xB7 ${escapeHtml(s.persona || "\u2014")} \xB7 ${escapeHtml(s.pack || "\u2014")}${linked} + + + + + +
+ `; + root.appendChild(div); + } + } + async function upsertSample(patch) { + await SA2.request("AssistentUpsertTrainSample", patch); + await refreshSamples(); + } + function renderHfList() { + const root = $("sa_hf_list"); + if (!root) return; + root.innerHTML = ""; + const showAll = !!$("sa_hf_show_all")?.checked; + for (const r of state.hfResults) { + if (!showAll && r.gate === "rejected") continue; + const row = document.createElement("div"); + row.className = "sa-hf-row" + (state.hfSelected === r.id ? " sa-hf-row-active" : "") + (r.gate === "rejected" ? " sa-hf-rejected" : ""); + row.dataset.id = r.id; + const badge = r.gate === "ok" ? "ok" : r.gate === "mapping" ? "map" : "no"; + row.innerHTML = `${escapeHtml(r.gate)}${escapeHtml(r.id)}${escapeHtml(r.reason || "")}`; + root.appendChild(row); + } + } + async function searchHf() { + const q = ($("sa_hf_search")?.value || "").trim(); + setTrainStatus("\u041F\u043E\u0438\u0441\u043A\u2026"); + try { + const data = await SA2.request("AssistentSearchHfDatasets", { + q, + limit: 24, + show_all: !!$("sa_hf_show_all")?.checked + }); + state.hfResults = data?.results || []; + renderHfList(); + setTrainStatus(`\u041D\u0430\u0439\u0434\u0435\u043D\u043E: ${state.hfResults.length}`); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + async function checkHfLink() { + const link = ($("sa_hf_link")?.value || "").trim(); + const status = $("sa_hf_status"); + if (!link) return; + if (status) status.textContent = "\u041F\u0440\u043E\u0432\u0435\u0440\u044F\u044E\u2026"; + try { + const data = await SA2.request("AssistentCheckHfDataset", { dataset: link }); + state.hfCheck = data; + state.hfSelected = data.id; + if (status) { + status.textContent = data.gate === "rejected" ? `\u041E\u0442\u043A\u043B\u043E\u043D\u0435\u043D\u043E: ${data.reason}` : `${data.gate}: ${data.reason || "OK"}`; + } + const preview = $("sa_hf_preview"); + if (preview) { + preview.hidden = false; + preview.textContent = JSON.stringify(data.sample_rows || data.features || data, null, 2).slice(0, 8e3); + } + const importRow = $("sa_hf_import_row"); + if (importRow) importRow.hidden = data.gate === "rejected"; + } catch (e) { + if (status) status.textContent = String(e.message || e); + } + } + async function importHf() { + if (!state.hfSelected && !state.hfCheck?.id) { + setTrainStatus("\u0421\u043D\u0430\u0447\u0430\u043B\u0430 \u043F\u0440\u043E\u0432\u0435\u0440\u044C \u043D\u0430\u0431\u043E\u0440"); + return; + } + const id = state.hfSelected || state.hfCheck.id; + const limit = Number($("sa_hf_import_limit")?.value) || 200; + try { + const data = await SA2.request("AssistentImportHfDataset", { dataset: id, limit }); + setTrainStatus(`\u0418\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E: ${data.imported}${data.runner_only ? " (runner-only)" : ""}`); + await refreshSamples(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + async function syncModelfileModels() { + try { + const baseUrl = $("sa_base_url")?.value || localStorage.getItem("swarm_assistent_base_url") || ""; + const data = await SA2.request("AssistentListModels", { baseUrl }); + const models = data?.models || []; + for (const selId of ["sa_modelfile_base"]) { + const sel = $(selId); + if (!sel) continue; + const cur = sel.value; + sel.innerHTML = ''; + for (const m of models) { + const opt = document.createElement("option"); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + } + if (cur) sel.value = cur; + } + const personaSel = $("sa_modelfile_persona"); + if (personaSel && $("sa_persona")) { + personaSel.innerHTML = $("sa_persona").innerHTML; + personaSel.value = $("sa_persona").value || "neutral"; + } + } catch (e) { + } + } + async function createModelfile() { + setTrainStatus("\u0421\u043E\u0437\u0434\u0430\u044E \u043C\u043E\u0434\u0435\u043B\u044C\u2026"); + try { + const data = await SA2.request("AssistentCreateOllamaModel", { + base_url: $("sa_base_url")?.value, + base_model: $("sa_modelfile_base")?.value, + name: $("sa_modelfile_name")?.value, + persona: $("sa_modelfile_persona")?.value, + system: $("sa_modelfile_system")?.value, + shots: Number($("sa_modelfile_shots")?.value) || 8, + num_ctx: Number($("sa_modelfile_num_ctx")?.value) || 16384, + temperature: Number($("sa_modelfile_temp")?.value) || 0.7 + }); + setTrainStatus(`\u0413\u043E\u0442\u043E\u0432\u043E: ${data.name}`); + SA2.app?.refreshModels?.(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + function setTrainMode(mode) { + $("sa_train_form_modelfile").hidden = mode !== "modelfile"; + $("sa_train_form_qlora").hidden = mode !== "qlora"; + } + function setTrainingLock(on, text) { + const root = $("swarm_assistent_root"); + const banner = $("sa_train_banner"); + if (root) root.classList.toggle("sa-root-training-lock", !!on); + if (banner) { + banner.hidden = !on; + const t = $("sa_train_banner_text"); + if (t && text) t.textContent = text; + } + SA2.app?.setTrainingLock?.(!!on); + } + async function pollTrainJob() { + try { + const data = await SA2.request("AssistentGetTrainJob", {}); + const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null; + const active = data?.training_active || data?.job?.status === "running"; + setTrainingLock(active, prog?.status === "running" ? `\u0422\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430 \xB7 ${prog?.percent ?? 0}%` : "\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430\u2026"); + const logEl = $("sa_train_log"); + const bar = $("sa_train_progress_fill"); + const box = $("sa_train_progress"); + if (prog) { + if (box) box.hidden = false; + if (bar && prog.percent != null) bar.style.width = `${prog.percent}%`; + if (logEl && prog.log) logEl.textContent = prog.log; + } + if (!active) { + clearInterval(state.polling); + state.polling = null; + $("sa_btn_qlora_cancel").hidden = true; + } + } catch (e) { + } + } + async function startQlora() { + setTrainStatus("\u0417\u0430\u043F\u0443\u0441\u043A\u2026"); + try { + await SA2.request("AssistentStartTrainJob", { + base_url: $("sa_base_url")?.value, + chat_model: $("sa_model")?.value, + base_model: $("sa_qlora_base")?.value, + output_name: $("sa_qlora_name")?.value, + rank: Number($("sa_qlora_rank")?.value) || 16, + alpha: Number($("sa_qlora_alpha")?.value) || 32, + lr: Number($("sa_qlora_lr")?.value) || 2e-4, + epochs: Number($("sa_qlora_epochs")?.value) || 3, + seq_len: Number($("sa_qlora_seq")?.value) || 2048, + four_bit: !!$("sa_qlora_4bit")?.checked, + hf_dataset: ($("sa_qlora_hf_dataset")?.value || "").trim() || void 0 + }); + $("sa_btn_qlora_cancel").hidden = false; + setTrainingLock(true, "\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430\u2026"); + if (state.polling) clearInterval(state.polling); + state.polling = setInterval(pollTrainJob, 1500); + pollTrainJob(); + setTrainStatus("\u0422\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430 \u0437\u0430\u043F\u0443\u0449\u0435\u043D\u0430"); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + async function cancelQlora() { + try { + await SA2.request("AssistentCancelTrainJob", {}); + setTrainingLock(false); + setTrainStatus("\u041E\u0442\u043C\u0435\u043D\u0435\u043D\u043E"); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + async function refreshTrainModels() { + const root = $("sa_train_models_list"); + if (!root) return; + try { + const data = await SA2.request("AssistentListModels", { baseUrl: $("sa_base_url")?.value }); + const models = data?.models || []; + root.innerHTML = models.length ? models.map((m) => `
${escapeHtml(m)}
`).join("") : '
\u041D\u0435\u0442 \u043C\u043E\u0434\u0435\u043B\u0435\u0439
'; + } catch (e) { + root.innerHTML = `
${escapeHtml(e.message)}
`; + } + } + async function saveRunner() { + try { + await SA2.request("AssistentSaveRunnerSettings", { + python: $("sa_runner_python")?.value, + kind: $("sa_runner_kind")?.value, + workdir: $("sa_runner_workdir")?.value, + cmd: $("sa_runner_cmd")?.value, + gguf_script: $("sa_runner_gguf_script")?.value + }); + setTrainStatus("\u0420\u0430\u043D\u043D\u0435\u0440 \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D"); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + async function loadRunner() { + try { + const data = await SA2.request("AssistentGetRunnerSettings", {}); + const s = data?.settings || {}; + if ($("sa_runner_python") && s.python) $("sa_runner_python").value = s.python; + if ($("sa_runner_kind") && s.kind) $("sa_runner_kind").value = s.kind; + if ($("sa_runner_workdir") && s.workdir) $("sa_runner_workdir").value = s.workdir; + if ($("sa_runner_cmd") && s.cmd) $("sa_runner_cmd").value = s.cmd; + if ($("sa_runner_gguf_script") && s.gguf_script) $("sa_runner_gguf_script").value = s.gguf_script; + } catch (e) { + } + } + function wireTraining() { + if (window.__saTrainingWired) return; + window.__saTrainingWired = true; + document.querySelectorAll("#sa_training .sa-ttab").forEach((btn) => { + btn.addEventListener("click", () => setTrainingTab(btn.getAttribute("data-ttab"))); + }); + $("sa_btn_agent_sync")?.addEventListener("click", syncAllToAgent); + $("sa_agent_heard_enabled")?.addEventListener("change", saveAgentHeardSettings); + $("sa_agent_auto_link")?.addEventListener("change", saveAgentHeardSettings); + $("sa_agent_heard_quota")?.addEventListener("change", saveAgentHeardSettings); + loadAgentHeardSettings(); + $("sa_btn_train_from_chats")?.addEventListener("click", async () => { + try { + const data = await SA2.request("AssistentBuildDatasetFromChats", {}); + setTrainStatus(`\u0418\u0437 \u0447\u0430\u0442\u043E\u0432: +${data.added}`); + await refreshSamples(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + }); + $("sa_btn_train_import_file")?.addEventListener("click", () => $("sa_train_import_file")?.click()); + $("sa_train_import_file")?.addEventListener("change", async (e) => { + const file = e.target?.files?.[0]; + if (!file) return; + const text = await file.text(); + try { + const data = await SA2.request("AssistentImportDataset", { format: "auto", content: text }); + setTrainStatus(`\u0418\u043C\u043F\u043E\u0440\u0442: ${data.imported}`); + await refreshSamples(); + } catch (err) { + setTrainStatus(String(err.message || err)); + } + e.target.value = ""; + }); + $("sa_btn_train_export")?.addEventListener("click", async () => { + try { + const data = await SA2.request("AssistentExportDataset", { status: "approved" }); + if (data.content) { + const blob = new Blob([data.content], { type: "application/jsonl" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "assistent-dataset.jsonl"; + a.click(); + } + setTrainStatus(`\u042D\u043A\u0441\u043F\u043E\u0440\u0442: ${data.count} \u043F\u0440\u0438\u043C\u0435\u0440\u043E\u0432`); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + }); + $("sa_train_filter_status")?.addEventListener("change", refreshSamples); + $("sa_train_filter_persona")?.addEventListener("change", refreshSamples); + $("sa_train_samples")?.addEventListener("click", async (e) => { + const row = e.target.closest(".sa-train-sample"); + if (!row) return; + const id = row.dataset.id; + const sample = state.samples.find((s) => s.id === id); + if (!sample) return; + if (e.target.closest("[data-approve]")) { + await upsertSample({ ...sample, status: "approved" }); + await loadAgentHeardSettings(); + } else if (e.target.closest("[data-reject]")) { + await upsertSample({ ...sample, status: "rejected" }); + await loadAgentHeardSettings(); + } else if (e.target.closest("[data-link]")) { + try { + const data = await SA2.request("AssistentLinkTrainSampleToAgent", { id }); + state.agentLinked = data?.linked ?? state.agentLinked; + setAgentHeardStats(state.agentLinked); + setTrainStatus("\u041F\u0440\u0438\u043C\u0435\u0440 \u043F\u043E\u0434\u043A\u043B\u044E\u0447\u0451\u043D \u043A \u0430\u0433\u0435\u043D\u0442\u0443"); + await refreshSamples(); + } catch (err) { + setTrainStatus(String(err.message || err)); + } + } else if (e.target.closest("[data-unlink]")) { + try { + const data = await SA2.request("AssistentUnlinkTrainSampleFromAgent", { id }); + state.agentLinked = data?.linked ?? state.agentLinked; + setAgentHeardStats(state.agentLinked); + setTrainStatus("\u041F\u0440\u0438\u043C\u0435\u0440 \u043E\u0442\u043A\u043B\u044E\u0447\u0451\u043D \u043E\u0442 \u0430\u0433\u0435\u043D\u0442\u0430"); + await refreshSamples(); + } catch (err) { + setTrainStatus(String(err.message || err)); + } + } else if (e.target.closest("[data-del]")) { + if (window.confirm("\u0423\u0434\u0430\u043B\u0438\u0442\u044C \u043F\u0440\u0438\u043C\u0435\u0440?")) { + await SA2.request("AssistentDeleteTrainSample", { id }); + await refreshSamples(); + } + } + }); + $("sa_btn_hf_search")?.addEventListener("click", searchHf); + $("sa_hf_show_all")?.addEventListener("change", () => { + renderHfList(); + }); + $("sa_hf_list")?.addEventListener("click", async (e) => { + const row = e.target.closest(".sa-hf-row"); + if (!row || row.classList.contains("sa-hf-rejected")) return; + state.hfSelected = row.dataset.id; + $("sa_hf_link").value = row.dataset.id; + renderHfList(); + await checkHfLink(); + }); + $("sa_btn_hf_check")?.addEventListener("click", checkHfLink); + $("sa_btn_hf_import")?.addEventListener("click", importHf); + document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => { + r.addEventListener("change", () => setTrainMode(r.value)); + }); + $("sa_btn_modelfile_create")?.addEventListener("click", createModelfile); + $("sa_btn_qlora_start")?.addEventListener("click", startQlora); + $("sa_btn_qlora_cancel")?.addEventListener("click", cancelQlora); + $("sa_btn_train_models_refresh")?.addEventListener("click", refreshTrainModels); + $("sa_btn_save_runner")?.addEventListener("click", saveRunner); + loadRunner(); + setTrainMode("modelfile"); + } + SA2.training = { + render() { + wireTraining(); + setTrainingTab(state.ttab); + }, + async curateFromChat(messages, meta) { + try { + await SA2.request("AssistentUpsertTrainSample", { + source: "chat", + chat_id: meta?.chatId, + persona: meta?.persona, + pack: meta?.pack, + status: meta?.status || "approved", + messages + }); + return true; + } catch (e) { + console.warn("curateFromChat", e); + return false; + } + }, + setTrainingLock, + pollTrainJob + }; + } + // src/main.js window.SA = window.SA || {}; attachApi(window.SA); @@ -9063,4 +9679,5 @@ ${HELP_TEXT}`); window.SA.PATCH_KEYS = keys; } }; + attachTraining(window.SA); })(); diff --git a/Assets/assistent.css b/Assets/assistent.css index ecced26..32f691e 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -17,6 +17,137 @@ opacity: 0.95; } +.sa-appbar { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; + padding: 0.35rem 0.5rem 0.55rem; + border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent); + flex: 0 0 auto; +} + +.sa-appbar-brand { + display: flex; + align-items: center; + gap: 0.45rem; + min-width: 0; +} + +.sa-appbar-title { + font-weight: 700; + letter-spacing: 0.04em; + font-size: 0.92rem; +} + +.sa-app-tabs { + display: inline-flex; + gap: 0.25rem; + flex: 1; + min-width: 0; + flex-wrap: wrap; +} + +.sa-app-tab { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: transparent; + color: inherit; + border-radius: 999px; + padding: 0.22rem 0.75rem; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; + opacity: 0.72; +} + +.sa-app-tab-active { + opacity: 1; + background: color-mix(in srgb, currentColor 14%, transparent); + border-color: color-mix(in srgb, currentColor 42%, transparent); +} + +.sa-train-banner { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + border: 1px solid color-mix(in srgb, #f5a623 55%, currentColor); + background: color-mix(in srgb, #f5a623 16%, transparent); + flex: 0 0 auto; +} + +.sa-views { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.sa-views > .sa-view { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.sa-views > .sa-view[hidden] { + display: none !important; +} + +#sa_view_chat .sa-layout { + flex: 1; + min-height: 0; +} + +.sa-chat-workspace { + flex: 1 1 auto; + display: flex; + min-width: 0; + min-height: 0; + gap: 0; +} + +.sa-chat-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.sa-chats-drawer { + flex: 0 0 var(--sa-chats-drawer-width, 16rem); + width: var(--sa-chats-drawer-width, 16rem); + display: flex; + flex-direction: column; + gap: 0.35rem; + min-height: 0; + border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent); + background: color-mix(in srgb, currentColor 4%, transparent); + padding: 0.45rem 0.5rem; + overflow: hidden; + transition: width 0.2s ease, flex-basis 0.2s ease, opacity 0.2s ease; +} + +.sa-chats-drawer[hidden] { + display: none !important; +} + +.sa-chats-drawer-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + font-size: 0.82rem; +} + +.sa-chats-drawer-actions { + display: inline-flex; + gap: 0.2rem; +} + .sa-layout { display: flex; flex: 1; @@ -411,22 +542,21 @@ background: color-mix(in srgb, currentColor 10%, transparent); } -.sa-chats-panel { - position: absolute; - z-index: 50; - top: calc(100% - 1px); - left: 0.5rem; - right: 0.5rem; - max-width: 26rem; - max-height: min(50vh, 22rem); - display: flex; - flex-direction: column; - gap: 0.35rem; - padding: 0.55rem; - border-radius: 0 0 0.5rem 0.5rem; - border: 1px solid color-mix(in srgb, currentColor 28%, transparent); - background: color-mix(in srgb, #161616 94%, currentColor); - box-shadow: 0 12px 28px color-mix(in srgb, #000 40%, transparent); +.sa-chats-panel, +.sa-chats-drawer { + /* drawer lives in .sa-chat-workspace — not a dropdown */ + position: static; + z-index: auto; + top: auto; + left: auto; + right: auto; + max-width: none; + max-height: none; + box-shadow: none; + border-radius: 0; + border: none; + border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent); + background: color-mix(in srgb, currentColor 4%, transparent); } .sa-chats-panel-head { @@ -1491,7 +1621,13 @@ vertical-align: middle; } -.sa-subtab { +/* app-level tabs replace in-pane subtabs */ +.sa-subtabs { + display: none; +} + +.sa-subtab, +.sa-app-tab { border: 1px solid color-mix(in srgb, currentColor 22%, transparent); background: transparent; color: inherit; @@ -1503,7 +1639,8 @@ opacity: 0.7; } -.sa-subtab-active { +.sa-subtab-active, +.sa-app-tab-active { opacity: 1; background: color-mix(in srgb, currentColor 12%, transparent); } @@ -2016,4 +2153,316 @@ width: 98%; max-height: 96%; } + .sa-chat-workspace { + position: relative; + } + .sa-chats-drawer:not([hidden]) { + position: absolute; + z-index: 60; + top: 0; + right: 0; + bottom: 0; + width: min(18rem, 92vw); + flex: none; + box-shadow: -8px 0 24px color-mix(in srgb, #000 35%, transparent); + } +} + +/* Training tab */ +.sa-training { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + border-radius: 0.55rem; + background: color-mix(in srgb, currentColor 3%, transparent); + overflow: hidden; +} + +.sa-training-tabs { + display: flex; + gap: 0.25rem; + padding: 0.45rem 0.55rem; + border-bottom: 1px solid color-mix(in srgb, currentColor 16%, transparent); + flex-wrap: wrap; +} + +.sa-ttab { + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + background: transparent; + color: inherit; + border-radius: 999px; + padding: 0.15rem 0.65rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + opacity: 0.72; +} + +.sa-ttab-active { + opacity: 1; + background: color-mix(in srgb, currentColor 12%, transparent); +} + +.sa-training-panes { + flex: 1; + overflow: auto; + min-height: 0; + padding: 0.55rem 0.65rem 0.75rem; +} + +.sa-tpane { + display: flex; + flex-direction: column; + gap: 0.55rem; + min-height: 0; +} + +.sa-train-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.sa-train-stats { + font-size: 0.78rem; + font-weight: 600; + opacity: 0.85; + margin-right: 0.25rem; +} + +.sa-agent-heard-panel { + border: 1px solid color-mix(in srgb, currentColor 18%, transparent); + border-radius: 0.45rem; + padding: 0.55rem 0.65rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 0.35rem; +} + +.sa-agent-heard-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.65rem; +} + +.sa-agent-heard-stats { + font-size: 0.78rem; + font-weight: 600; + opacity: 0.8; +} + +.sa-agent-heard-hint { + margin: 0; + font-size: 0.78rem; + opacity: 0.78; + line-height: 1.35; +} + +.sa-agent-heard-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.75rem; +} + +.sa-agent-heard-controls label { + display: inline-flex; + align-items: center; + gap: 0.3rem; + font-size: 0.82rem; +} + +.sa-agent-heard-controls input[type="number"] { + width: 2.75rem; +} + +.sa-hf-panel { + border: 1px solid color-mix(in srgb, currentColor 18%, transparent); + border-radius: 0.45rem; + padding: 0.55rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.sa-hf-search-row, +.sa-hf-link-row, +.sa-hf-import-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.sa-hf-search, +.sa-hf-link { + flex: 1 1 12rem; + min-width: 8rem; +} + +.sa-hf-status { + font-size: 0.76rem; + opacity: 0.85; + min-height: 1.1rem; +} + +.sa-hf-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 12rem; + overflow: auto; +} + +.sa-hf-row { + display: flex; + align-items: flex-start; + gap: 0.35rem; + padding: 0.35rem 0.45rem; + border-radius: 0.35rem; + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + cursor: pointer; + font-size: 0.76rem; +} + +.sa-hf-row:hover { + background: color-mix(in srgb, currentColor 8%, transparent); +} + +.sa-hf-row.sa-hf-row-active { + border-color: color-mix(in srgb, currentColor 42%, transparent); + background: color-mix(in srgb, currentColor 12%, transparent); +} + +.sa-hf-row.sa-hf-rejected { + opacity: 0.55; + cursor: not-allowed; +} + +.sa-hf-badge { + font-size: 0.68rem; + padding: 0.05rem 0.35rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, currentColor 22%, transparent); + white-space: nowrap; +} + +.sa-hf-badge-ok { + border-color: color-mix(in srgb, #4caf50 50%, currentColor); +} + +.sa-hf-badge-map { + border-color: color-mix(in srgb, #f5a623 50%, currentColor); +} + +.sa-hf-badge-no { + border-color: color-mix(in srgb, #e74c3c 50%, currentColor); +} + +.sa-hf-preview { + font-size: 0.72rem; + max-height: 10rem; + overflow: auto; + border: 1px solid color-mix(in srgb, currentColor 14%, transparent); + border-radius: 0.35rem; + padding: 0.4rem; + white-space: pre-wrap; + font-family: ui-monospace, monospace; +} + +.sa-train-samples { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 24rem; + overflow: auto; +} + +.sa-train-sample { + border: 1px solid color-mix(in srgb, currentColor 16%, transparent); + border-radius: 0.4rem; + padding: 0.45rem 0.55rem; + font-size: 0.76rem; +} + +.sa-train-sample-head { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; + margin-bottom: 0.35rem; +} + +.sa-train-sample textarea { + width: 100%; + box-sizing: border-box; + min-height: 3rem; + font-size: 0.74rem; +} + +.sa-msg-curate { + display: inline-flex; + gap: 0.25rem; + margin-top: 0.35rem; +} + +.sa-msg-curate button { + font-size: 0.72rem !important; + padding: 0.1rem 0.4rem !important; +} + +.sa-train-modes { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sa-train-form label { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.78rem; + margin-bottom: 0.35rem; +} + +.sa-train-progress-bar { + height: 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, currentColor 12%, transparent); + overflow: hidden; +} + +.sa-train-progress-fill { + height: 100%; + width: 0%; + background: color-mix(in srgb, #4caf50 70%, currentColor); + transition: width 0.3s ease; +} + +.sa-train-log { + max-height: 14rem; + overflow: auto; + font-size: 0.72rem; + margin: 0.35rem 0 0; + padding: 0.45rem; + border-radius: 0.35rem; + background: color-mix(in srgb, #000 22%, transparent); +} + +.sa-train-models-list { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sa-root-training-lock .sa-composer, +.sa-root-training-lock #sa_btn_send, +.sa-root-training-lock #sa_btn_build_gen { + pointer-events: none; + opacity: 0.45; } diff --git a/AssistentChatPipeline.cs b/AssistentChatPipeline.cs index a2eec2d..4170e60 100644 --- a/AssistentChatPipeline.cs +++ b/AssistentChatPipeline.cs @@ -185,6 +185,7 @@ public partial class SwarmAssistentExtension { AssistentMemory.RetrieveOptions opt = MemoryRetrieveOptions(pid); hits = await Memory.RetrieveAsync(root, retrieveQuery, opt.TopK, embed, Config.PersonaExtendsChain(pid), opt); + hits = FilterHeardHitsIfDisabled(hits); } catch (Exception ex) { @@ -192,7 +193,7 @@ public partial class SwarmAssistentExtension } } - string enrichedContext = InjectMemoryHits(contextJson, hits); + string enrichedContext = InjectMemoryHits(contextJson, hits, pid); if (!slimDebug) { enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName); @@ -338,24 +339,51 @@ public partial class SwarmAssistentExtension AssistentMemory.RetrieveOptions MemoryRetrieveOptions(string pid) { JObject a = Config.LoadAssistant(pid) ?? new JObject(); + JObject agent = Config.LoadTrainingAgent(); AssistentMemory.RetrieveOptions opt = new() { TopK = a["memory_top_k"]?.Value() ?? 8, MinScore = a["memory_min_score"]?.Value() ?? 0.32f, ApplyQuotas = true, }; - if (a["memory_quotas"] is JObject quotas) + Dictionary quotas = AssistentMemory.CopyDefaultQuotas(); + if (a["memory_quotas"] is JObject qOverrides) { - Dictionary d = new(StringComparer.OrdinalIgnoreCase); - foreach (JProperty p in quotas.Properties()) + foreach (JProperty p in qOverrides.Properties()) { - d[p.Name] = p.Value?.Value() ?? 2; + quotas[p.Name] = p.Value?.Value() ?? 2; } - opt.Quotas = d; } + if (agent["enabled"]?.Value() != false) + { + quotas["heard"] = agent["heard_quota"]?.Value() ?? 3; + } + else + { + quotas.Remove("heard"); + } + opt.Quotas = quotas; return opt; } + JArray FilterHeardHitsIfDisabled(JArray hits) + { + if (Config.LoadTrainingAgent()["enabled"]?.Value() != false) + { + return hits; + } + JArray filtered = []; + foreach (JToken t in hits ?? []) + { + if (t is JObject ho && string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + filtered.Add(t); + } + return filtered; + } + async Task<(string follow, JArray civitai)> RunToolHop( Session session, string root, @@ -413,6 +441,38 @@ public partial class SwarmAssistentExtension + rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```", null); } + if (tool == "heard_search") + { + if (Config.LoadTrainingAgent()["enabled"]?.Value() == false) + { + return ("heard_search disabled in training-agent settings.", null); + } + string q = patch["memory_query"]?.ToString()?.Trim() + ?? patch["search_query"]?.ToString()?.Trim() + ?? ExtractMemoryQuery(patch); + if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("heard:" + q)) + { + return (null, null); + } + int topK = Config.LoadTrainingAgent()["heard_quota"]?.Value() ?? 3; + JArray rows = await Memory.SearchAsync(root, q, AssistentMemory.HeardKind, topK, embed, chain); + JArray examples = []; + foreach (JToken t in rows) + { + if (t is JObject ho) + { + JObject ex = Memory.BuildHeardExampleFromHit(ho, chain); + if (ex is not null) + { + examples.Add(ex); + } + } + } + return ( + "heard_search — curated dialogue examples the assistant learned (style/reference, not hard rules). Use tone and structure; omit heard_search unless you need more examples.\n```json\n" + + examples.ToString(Newtonsoft.Json.Formatting.None) + "\n```", + null); + } if (tool == "lookup_tags") { string q = ExtractTagQuery(patch); @@ -599,7 +659,7 @@ public partial class SwarmAssistentExtension return outRows; } - string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null) + string InjectMemoryHits(string contextJson, JArray hits, string personaId = null) { JObject ctx; try @@ -614,7 +674,7 @@ public partial class SwarmAssistentExtension int hitChars = 240; try { - string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral"; + string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral"; hitChars = Config?.LoadAssistant(pidHit)?["memory_hit_chars"]?.Value() ?? 240; } catch @@ -623,7 +683,11 @@ public partial class SwarmAssistentExtension } hitChars = Math.Max(80, Math.Min(hitChars, 800)); + string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral"; + IEnumerable chain = Config?.PersonaExtendsChain(pid) ?? []; + JArray clippedHits = []; + JArray heardExamples = []; foreach (JToken t in hits ?? []) { if (t is not JObject ho) @@ -634,6 +698,15 @@ public partial class SwarmAssistentExtension { continue; } + if (string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase)) + { + JObject ex = Memory?.BuildHeardExampleFromHit(ho, chain); + if (ex is not null) + { + heardExamples.Add(ex); + } + continue; + } JObject copy = (JObject)ho.DeepClone(); string text = copy["text"]?.ToString() ?? ""; if (text.Length > hitChars) @@ -644,6 +717,14 @@ public partial class SwarmAssistentExtension clippedHits.Add(copy); } ctx["memory_hits"] = clippedHits; + if (heardExamples.Count > 0) + { + ctx["heard_examples"] = heardExamples; + } + else + { + ctx.Remove("heard_examples"); + } ctx.Remove("taste_profile"); ctx.Remove("enabled_loras"); // alias of selected_loras — do not double-feed try diff --git a/AssistentConfig.cs b/AssistentConfig.cs index 4777dfa..cce693e 100644 --- a/AssistentConfig.cs +++ b/AssistentConfig.cs @@ -1380,6 +1380,40 @@ public sealed class AssistentConfig } } + public JObject LoadTrainingRunner() + => TryReadJson(Path.Combine(_overlayRoot, "training-runner.json")) ?? new JObject(); + + public void SaveTrainingRunner(JObject settings) + { + lock (_lock) + { + Directory.CreateDirectory(_overlayRoot); + string path = Path.Combine(_overlayRoot, "training-runner.json"); + JObject merged = DeepMerge(LoadTrainingRunner(), settings ?? new JObject()); + File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); + } + } + + public JObject LoadTrainingAgent() + => TryReadJson(Path.Combine(_overlayRoot, "training-agent.json")) + ?? new JObject + { + ["enabled"] = true, + ["auto_link_on_approve"] = true, + ["heard_quota"] = 3, + }; + + public void SaveTrainingAgent(JObject settings) + { + lock (_lock) + { + Directory.CreateDirectory(_overlayRoot); + string path = Path.Combine(_overlayRoot, "training-agent.json"); + JObject merged = DeepMerge(LoadTrainingAgent(), settings ?? new JObject()); + File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); + } + } + public JObject LoadOllamaRoles() { return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json")) diff --git a/AssistentHuggingFace.cs b/AssistentHuggingFace.cs new file mode 100644 index 0000000..e75dd5d --- /dev/null +++ b/AssistentHuggingFace.cs @@ -0,0 +1,483 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using SwarmUI.Accounts; +using SwarmUI.Utils; + +namespace Mrleo1nid.SwarmAssistent; + +/// Hugging Face datasets: search, compatibility gate, preview, import. +public partial class SwarmAssistentExtension +{ + static readonly Regex HfRepoIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}(/[A-Za-z0-9][A-Za-z0-9._-]{0,95})?$", RegexOptions.Compiled); + + const string HfDatasetsServer = "https://datasets-server.huggingface.co"; + const string HfHubApi = "https://huggingface.co/api/datasets"; + + static string GetHfToken(Session session) + => session?.User?.GetGenericData("huggingface_api", "key")?.Trim(); + + static HttpRequestMessage HfRequest(string url, Session session) + { + HttpRequestMessage req = new(HttpMethod.Get, url); + string token = GetHfToken(session); + if (!string.IsNullOrWhiteSpace(token)) + { + req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + return req; + } + + /// Normalize owner/name or HF datasets URL. Returns null when invalid. + public static string NormalizeHfDatasetId(string raw) + { + string s = (raw ?? "").Trim(); + if (string.IsNullOrWhiteSpace(s)) + { + return null; + } + if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + if (!Uri.TryCreate(s, UriKind.Absolute, out Uri uri)) + { + return null; + } + if (!string.Equals(uri.Host, "huggingface.co", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + string[] parts = uri.AbsolutePath.Trim('/').Split('/'); + if (parts.Length < 2 || !string.Equals(parts[0], "datasets", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + s = $"{parts[1]}/{parts[2]}"; + } + s = s.Trim().TrimEnd('/'); + return HfRepoIdRe.IsMatch(s) ? s : null; + } + + public async Task AssistentSearchHfDatasets(Session session, string q = null, int limit = 20, bool show_all = false) + { + int take = Math.Clamp(limit, 1, 50); + string search = (q ?? "").Trim(); + StringBuilder url = new($"{HfHubApi}?limit={take}&full=true"); + url.Append("&filter=task_categories:text-generation"); + url.Append("&filter=modality:text"); + if (!string.IsNullOrWhiteSpace(search)) + { + url.Append("&search=").Append(Uri.EscapeDataString(search)); + } + try + { + using HttpRequestMessage req = HfRequest(url.ToString(), session); + using HttpResponseMessage resp = await HttpClient.SendAsync(req); + string body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + return new JObject { ["error"] = $"HF search HTTP {(int)resp.StatusCode}: {Clip(body, 300)}" }; + } + JArray rawList = JArray.Parse(body); + JArray results = []; + foreach (JToken item in rawList) + { + if (item is not JObject o) + { + continue; + } + string id = o["id"]?.ToString(); + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + JObject check = await CheckHfDatasetInternal(session, id, useCache: true); + string gate = check["gate"]?.ToString() ?? "rejected"; + if (!show_all && gate == "rejected") + { + continue; + } + results.Add(new JObject + { + ["id"] = id, + ["title"] = o["id"], + ["downloads"] = o["downloads"], + ["gate"] = gate, + ["reason"] = check["reason"], + ["schema"] = check["schema"], + }); + } + return new JObject { ["success"] = true, ["results"] = results, ["has_hf_token"] = !string.IsNullOrWhiteSpace(GetHfToken(session)) }; + } + catch (Exception ex) + { + return new JObject { ["error"] = $"HF search: {ex.Message}" }; + } + } + + public async Task AssistentCheckHfDataset(Session session, string dataset) + { + string id = NormalizeHfDatasetId(dataset); + if (id is null) + { + return new JObject { ["success"] = false, ["error"] = "Нужен owner/name или ссылка huggingface.co/datasets/…" }; + } + JObject check = await CheckHfDatasetInternal(session, id, useCache: false); + check["success"] = check["gate"]?.ToString() != "rejected"; + check["id"] = id; + return check; + } + + async Task CheckHfDatasetInternal(Session session, string datasetId, bool useCache) + { + string cacheKey = $"hf:{datasetId}"; + if (useCache) + { + JObject cached = Memory.GetKvObject(KvHfDatasetCache)?[cacheKey] as JObject; + if (cached is not null && cached["checked_at"]?.Value() > DateTimeOffset.UtcNow.AddHours(-6).ToUnixTimeMilliseconds()) + { + return cached; + } + } + JObject result = new() { ["id"] = datasetId, ["gate"] = "rejected", ["reason"] = "unknown" }; + try + { + using HttpRequestMessage validReq = HfRequest($"{HfDatasetsServer}/is-valid?dataset={Uri.EscapeDataString(datasetId)}", session); + using HttpResponseMessage validResp = await HttpClient.SendAsync(validReq); + string validBody = await validResp.Content.ReadAsStringAsync(); + if (!validResp.IsSuccessStatusCode) + { + result["reason"] = $"is-valid HTTP {(int)validResp.StatusCode}"; + return CacheHfCheck(cacheKey, result); + } + JObject valid = JObject.Parse(validBody); + bool viewer = valid["viewer"]?.Value() == true; + bool preview = valid["preview"]?.Value() == true; + if (!viewer && !preview) + { + result["reason"] = "Набор не читается через datasets (viewer/preview = false)"; + return CacheHfCheck(cacheKey, result); + } + using HttpRequestMessage splitReq = HfRequest($"{HfDatasetsServer}/splits?dataset={Uri.EscapeDataString(datasetId)}", session); + using HttpResponseMessage splitResp = await HttpClient.SendAsync(splitReq); + string splitBody = await splitResp.Content.ReadAsStringAsync(); + if (!splitResp.IsSuccessStatusCode) + { + result["reason"] = $"splits HTTP {(int)splitResp.StatusCode}"; + return CacheHfCheck(cacheKey, result); + } + JArray splits = JObject.Parse(splitBody)["splits"] as JArray ?? []; + if (splits.Count == 0) + { + result["reason"] = "Нет splits"; + return CacheHfCheck(cacheKey, result); + } + JObject first = splits[0] as JObject; + string config = first?["config"]?.ToString() ?? "default"; + string split = first?["split"]?.ToString() ?? "train"; + using HttpRequestMessage rowsReq = HfRequest($"{HfDatasetsServer}/first-rows?dataset={Uri.EscapeDataString(datasetId)}&config={Uri.EscapeDataString(config)}&split={Uri.EscapeDataString(split)}", session); + using HttpResponseMessage rowsResp = await HttpClient.SendAsync(rowsReq); + string rowsBody = await rowsResp.Content.ReadAsStringAsync(); + if (!rowsResp.IsSuccessStatusCode) + { + result["reason"] = $"first-rows HTTP {(int)rowsResp.StatusCode}: {Clip(rowsBody, 200)}"; + return CacheHfCheck(cacheKey, result); + } + JObject rowsData = JObject.Parse(rowsBody); + JObject features = rowsData["features"] as JObject; + (string gate, string reason, JObject schema) = ClassifyHfFeatures(features); + result["gate"] = gate; + result["reason"] = reason; + result["schema"] = schema; + result["config"] = config; + result["split"] = split; + result["features"] = features; + result["sample_rows"] = rowsData["rows"]; + result["runner_only"] = HasHugeSizeTag(datasetId); + return CacheHfCheck(cacheKey, result); + } + catch (Exception ex) + { + result["reason"] = ex.Message; + return CacheHfCheck(cacheKey, result); + } + } + + static bool HasHugeSizeTag(string datasetId) => false; + + JObject CacheHfCheck(string cacheKey, JObject result) + { + result["checked_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + try + { + JObject bag = Memory.GetKvObject(KvHfDatasetCache) ?? new JObject(); + bag[cacheKey] = result; + Memory.SetKvObject(KvHfDatasetCache, bag); + } + catch (Exception ex) + { + Logs.Debug($"CacheHfCheck: {ex.Message}"); + } + return result; + } + + static (string gate, string reason, JObject schema) ClassifyHfFeatures(JObject features) + { + if (features is null || !features.Properties().Any()) + { + return ("rejected", "Нет колонок (features пуст)", null); + } + HashSet names = new(StringComparer.OrdinalIgnoreCase); + foreach (JProperty p in features.Properties()) + { + names.Add(p.Name); + JToken dtype = p.Value?["dtype"] ?? p.Value?["type"]; + string dt = dtype?.ToString() ?? ""; + if (dt.Contains("image", StringComparison.OrdinalIgnoreCase) + || dt.Contains("audio", StringComparison.OrdinalIgnoreCase) + || dt.Contains("video", StringComparison.OrdinalIgnoreCase)) + { + return ("rejected", $"Мультимодальная колонка {p.Name} ({dt})", null); + } + } + if (names.Contains("chosen") && names.Contains("rejected")) + { + return ("rejected", "DPO-набор (chosen/rejected) — не для SFT", null); + } + if (names.Count == 1 && names.Contains("text")) + { + return ("rejected", "Предобучение (одна колонка text), не диалоги", null); + } + if (names.Contains("messages")) + { + return ("ok", "OpenAI messages", new JObject { ["kind"] = "messages" }); + } + if (names.Contains("conversations")) + { + return ("ok", "ShareGPT conversations", new JObject { ["kind"] = "conversations" }); + } + if (names.Contains("instruction") && names.Contains("output")) + { + return ("ok", "Alpaca instruction/output", new JObject { ["kind"] = "alpaca" }); + } + if (names.Contains("prompt") && (names.Contains("response") || names.Contains("completion") || names.Contains("answer"))) + { + string respCol = names.Contains("response") ? "response" : names.Contains("completion") ? "completion" : "answer"; + return ("ok", "prompt/response", new JObject { ["kind"] = "prompt_response", ["response_col"] = respCol }); + } + if (names.Contains("question") && names.Contains("answer")) + { + return ("ok", "question/answer", new JObject { ["kind"] = "qa" }); + } + List stringCols = []; + foreach (JProperty p in features.Properties()) + { + JToken dtype = p.Value?["dtype"] ?? p.Value?["type"]; + string dt = dtype?.ToString() ?? ""; + if (dt.Contains("string", StringComparison.OrdinalIgnoreCase) || dt == "value") + { + stringCols.Add(p.Name); + } + } + if (stringCols.Count >= 2) + { + return ("mapping", "Нужен ручной маппинг колонок", new JObject + { + ["kind"] = "custom", + ["columns"] = new JArray(stringCols), + }); + } + return ("rejected", "Схема не подходит для SFT", null); + } + + public async Task AssistentPreviewHfDataset(Session session, string dataset, string config = null, string split = null) + { + string id = NormalizeHfDatasetId(dataset); + if (id is null) + { + return new JObject { ["error"] = "invalid dataset id" }; + } + JObject check = await CheckHfDatasetInternal(session, id, useCache: true); + if (check["gate"]?.ToString() == "rejected") + { + return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected", ["check"] = check }; + } + return new JObject { ["success"] = true, ["check"] = check }; + } + + public async Task AssistentImportHfDataset(Session session, JObject raw) + { + string dataset = raw?["dataset"]?.ToString(); + int limit = raw?["limit"]?.Value() ?? 200; + JObject mapping = raw?["mapping"] as JObject; + string id = NormalizeHfDatasetId(dataset); + if (id is null) + { + return new JObject { ["error"] = "invalid dataset id" }; + } + JObject check = await CheckHfDatasetInternal(session, id, useCache: true); + string gate = check["gate"]?.ToString(); + if (gate == "rejected") + { + return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected" }; + } + if (gate == "mapping" && (mapping is null || mapping.Count == 0)) + { + return new JObject { ["error"] = "Нужен маппинг колонок", ["check"] = check }; + } + int take = Math.Clamp(limit, 1, 5000); + JArray rows = []; + string config = check["config"]?.ToString() ?? "default"; + string split = check["split"]?.ToString() ?? "train"; + int offset = 0; + while (rows.Count < take) + { + int chunk = Math.Min(100, take - rows.Count); + using HttpRequestMessage rowsReq = HfRequest($"{HfDatasetsServer}/rows?dataset={Uri.EscapeDataString(id)}&config={Uri.EscapeDataString(config)}&split={Uri.EscapeDataString(split)}&offset={offset}&length={chunk}", session); + using HttpResponseMessage rowsResp = await HttpClient.SendAsync(rowsReq); + string rowsBody = await rowsResp.Content.ReadAsStringAsync(); + if (!rowsResp.IsSuccessStatusCode) + { + break; + } + JObject parsed = JObject.Parse(rowsBody); + JArray batch = parsed["rows"] as JArray ?? []; + if (batch.Count == 0) + { + break; + } + foreach (JToken t in batch) + { + rows.Add(t); + } + offset += batch.Count; + if (batch.Count < chunk) + { + break; + } + } + if (rows.Count == 0) + { + rows = check["sample_rows"] as JArray ?? []; + } + int imported = 0; + foreach (JToken rowTok in rows.Take(take)) + { + if (rowTok is not JObject row) + { + continue; + } + JObject rowData = row["row"] as JObject ?? row; + JArray messages = ConvertHfRowToMessages(rowData, check["schema"] as JObject, mapping); + if (messages is null || messages.Count == 0) + { + continue; + } + Memory.UpsertTrainSample(new JObject + { + ["source"] = "hf", + ["hf_repo"] = id, + ["messages"] = messages, + ["status"] = "draft", + }); + imported++; + } + if (imported == 0 && check["runner_only"]?.Value() == true) + { + return new JObject { ["success"] = true, ["imported"] = 0, ["runner_only"] = true, ["id"] = id, ["note"] = "Большой набор — используй HF id в QLoRA-раннере" }; + } + return new JObject { ["success"] = true, ["imported"] = imported, ["id"] = id }; + } + + static JArray ConvertHfRowToMessages(JObject row, JObject schema, JObject mapping) + { + string kind = schema?["kind"]?.ToString() ?? mapping?["kind"]?.ToString(); + if (kind == "messages" && row["messages"] is JArray msgs) + { + return NormalizeMessagesArray(msgs); + } + if (kind == "conversations" && row["conversations"] is JArray conv) + { + JArray outArr = []; + foreach (JToken c in conv) + { + if (c is not JObject co) + { + continue; + } + string from = co["from"]?.ToString() ?? ""; + string val = co["value"]?.ToString() ?? ""; + string role = from is "human" or "user" ? "user" : from is "gpt" or "assistant" or "chatgpt" ? "assistant" : "user"; + outArr.Add(new JObject { ["role"] = role, ["content"] = val }); + } + return outArr.Count > 0 ? outArr : null; + } + if (kind == "alpaca") + { + string instr = row["instruction"]?.ToString() ?? ""; + string inp = row["input"]?.ToString() ?? ""; + string output = row["output"]?.ToString() ?? ""; + string user = string.IsNullOrWhiteSpace(inp) ? instr : $"{instr}\n{inp}"; + return new JArray + { + new JObject { ["role"] = "user", ["content"] = user }, + new JObject { ["role"] = "assistant", ["content"] = output }, + }; + } + if (kind == "prompt_response") + { + string respCol = schema?["response_col"]?.ToString() ?? "response"; + return new JArray + { + new JObject { ["role"] = "user", ["content"] = row["prompt"]?.ToString() ?? "" }, + new JObject { ["role"] = "assistant", ["content"] = row[respCol]?.ToString() ?? "" }, + }; + } + if (kind == "qa") + { + return new JArray + { + new JObject { ["role"] = "user", ["content"] = row["question"]?.ToString() ?? "" }, + new JObject { ["role"] = "assistant", ["content"] = row["answer"]?.ToString() ?? "" }, + }; + } + if (kind == "custom" && mapping is not null) + { + string userCol = mapping["user_col"]?.ToString(); + string asstCol = mapping["assistant_col"]?.ToString(); + if (!string.IsNullOrWhiteSpace(userCol) && !string.IsNullOrWhiteSpace(asstCol)) + { + return new JArray + { + new JObject { ["role"] = "user", ["content"] = row[userCol]?.ToString() ?? "" }, + new JObject { ["role"] = "assistant", ["content"] = row[asstCol]?.ToString() ?? "" }, + }; + } + } + return null; + } + + static JArray NormalizeMessagesArray(JArray msgs) + { + JArray outArr = []; + foreach (JToken m in msgs) + { + if (m is not JObject mo) + { + continue; + } + string role = mo["role"]?.ToString() ?? "user"; + string content = mo["content"]?.ToString() ?? mo["text"]?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + outArr.Add(new JObject { ["role"] = role, ["content"] = content }); + } + return outArr.Count > 0 ? outArr : null; + } +} diff --git a/AssistentMemory.Heard.cs b/AssistentMemory.Heard.cs new file mode 100644 index 0000000..bd462cf --- /dev/null +++ b/AssistentMemory.Heard.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Data.Sqlite; +using Newtonsoft.Json.Linq; +using SwarmUI.Utils; + +namespace Mrleo1nid.SwarmAssistent; + +/// Train samples linked to the agent as retrievable "heard" dialogue examples. +public sealed partial class AssistentMemory +{ + public const string HeardKind = "heard"; + public const string HeardSource = "train"; + + public static string FormatHeardEmbedText(JArray messages, string pack, string persona) + { + StringBuilder sb = new(); + if (!string.IsNullOrWhiteSpace(pack)) + { + sb.AppendLine($"pack: {pack.Trim()}"); + } + if (!string.IsNullOrWhiteSpace(persona)) + { + sb.AppendLine($"persona: {persona.Trim()}"); + } + foreach (JToken t in messages ?? []) + { + if (t is not JObject m) + { + continue; + } + string role = m["role"]?.ToString() ?? "user"; + string content = m["content"]?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + sb.AppendLine($"{role}: {content.Trim()}"); + } + return sb.ToString().Trim(); + } + + public void SetTrainSampleAgentLinked(string id, bool linked) + { + if (string.IsNullOrWhiteSpace(id)) + { + return; + } + lock (_lock) + { + EnsureOpen(); + if (!HasColumn("train_samples", "agent_linked")) + { + return; + } + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "UPDATE train_samples SET agent_linked = $v, updated_at = $u WHERE id = $id"; + cmd.Parameters.AddWithValue("$v", linked ? 1 : 0); + cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + cmd.Parameters.AddWithValue("$id", id.Trim()); + cmd.ExecuteNonQuery(); + } + } + + public int CountAgentLinkedTrainSamples() + { + lock (_lock) + { + EnsureOpen(); + if (!HasColumn("train_samples", "agent_linked")) + { + return 0; + } + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM train_samples WHERE agent_linked = 1"; + return Convert.ToInt32(cmd.ExecuteScalar()); + } + } + + public async Task LinkTrainSampleToAgentAsync(string baseUrl, JObject sample, string embedModel) + { + if (sample is null) + { + return false; + } + string id = sample["id"]?.ToString()?.Trim(); + JArray messages = sample["messages"] as JArray ?? []; + if (string.IsNullOrWhiteSpace(id) || messages.Count == 0) + { + return false; + } + string status = sample["status"]?.ToString() ?? "draft"; + if (!string.Equals(status, "approved", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + string persona = NormalizePersona(sample["persona"]?.ToString()); + string pack = sample["pack"]?.ToString()?.Trim() ?? ""; + string text = FormatHeardEmbedText(messages, pack, persona); + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + JObject meta = new() + { + ["train_sample_id"] = id, + ["pack"] = pack, + ["messages"] = messages, + ["source_type"] = sample["source"]?.ToString() ?? "manual", + }; + if (!string.IsNullOrWhiteSpace(sample["chat_id"]?.ToString())) + { + meta["chat_id"] = sample["chat_id"]?.ToString(); + } + float[] vec = await EmbedAsync(baseUrl, embedModel, text); + Upsert(HeardKind, id, text, HeardSource, meta, vec, persona); + SetTrainSampleAgentLinked(id, true); + return true; + } + + public void UnlinkTrainSampleFromAgent(JObject sample) + { + if (sample is null) + { + return; + } + string id = sample["id"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + return; + } + string persona = NormalizePersona(sample["persona"]?.ToString()); + Forget(HeardKind, id, HeardSource, persona); + if (persona != SharedPersona) + { + Forget(HeardKind, id, HeardSource, SharedPersona); + } + SetTrainSampleAgentLinked(id, false); + } + + public JObject BuildHeardExampleFromHit(JObject hit, IEnumerable personaChain = null) + { + if (hit is null) + { + return null; + } + string key = hit["key"]?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + JObject row = Get(HeardKind, key, personaChain); + JObject ex = new() + { + ["id"] = key, + ["score"] = hit["score"], + ["persona"] = hit["persona"], + ["source"] = hit["source"], + }; + JObject meta = null; + try + { + string metaRaw = row?["meta_json"]?.ToString(); + if (!string.IsNullOrWhiteSpace(metaRaw)) + { + meta = JObject.Parse(metaRaw); + } + } + catch + { + // ignore + } + if (meta?["pack"] is not null) + { + ex["pack"] = meta["pack"]; + } + if (meta?["messages"] is JArray msgs && msgs.Count > 0) + { + ex["messages"] = msgs; + } + else + { + ex["text"] = hit["text"]?.ToString() ?? row?["text"]?.ToString() ?? ""; + } + return ex; + } +} diff --git a/AssistentMemory.Training.cs b/AssistentMemory.Training.cs new file mode 100644 index 0000000..08b7518 --- /dev/null +++ b/AssistentMemory.Training.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Data.Sqlite; +using Newtonsoft.Json.Linq; +using SwarmUI.Utils; + +namespace Mrleo1nid.SwarmAssistent; + +/// Training datasets, samples, and job metadata in assistent.sqlite. +public sealed partial class AssistentMemory +{ + public const string KvHfDatasetCache = "hf_dataset_cache"; + + void EnsureTrainingSchema() + { + Exec( + """ + CREATE TABLE IF NOT EXISTS train_datasets ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + meta_json TEXT + ); + CREATE TABLE IF NOT EXISTS train_samples ( + id TEXT PRIMARY KEY, + dataset_id TEXT NOT NULL DEFAULT 'default', + source TEXT NOT NULL DEFAULT 'manual', + chat_id TEXT, + persona TEXT, + pack TEXT, + hf_repo TEXT, + messages_json TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'draft', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY(dataset_id) REFERENCES train_datasets(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_train_samples_status ON train_samples(status); + CREATE INDEX IF NOT EXISTS idx_train_samples_dataset ON train_samples(dataset_id); + CREATE TABLE IF NOT EXISTS train_jobs ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + config_json TEXT, + base_model TEXT, + output_name TEXT, + log_path TEXT, + progress_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + finished_at INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_train_jobs_status ON train_jobs(status); + """); + if (!HasColumn("train_samples", "agent_linked")) + { + Exec("ALTER TABLE train_samples ADD COLUMN agent_linked INTEGER NOT NULL DEFAULT 0"); + } + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "INSERT OR IGNORE INTO train_datasets(id, title, created_at, updated_at) VALUES('default', 'Default', $u, $u)"; + cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()); + cmd.ExecuteNonQuery(); + } + + public List ListTrainSamples(string status = null, string persona = null, string datasetId = null, int limit = 200) + { + lock (_lock) + { + EnsureOpen(); + int take = Math.Clamp(limit, 1, 2000); + List where = []; + if (!string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) + { + where.Add("status = $status"); + } + if (!string.IsNullOrWhiteSpace(persona) && !string.Equals(persona, "all", StringComparison.OrdinalIgnoreCase)) + { + where.Add("persona = $persona"); + } + if (!string.IsNullOrWhiteSpace(datasetId)) + { + where.Add("dataset_id = $ds"); + } + bool hasAgentLinked = HasColumn("train_samples", "agent_linked"); + string sql = hasAgentLinked + ? "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at, agent_linked FROM train_samples" + : "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at FROM train_samples"; + if (where.Count > 0) + { + sql += " WHERE " + string.Join(" AND ", where); + } + sql += " ORDER BY updated_at DESC LIMIT $lim"; + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = sql; + if (where.Any(w => w.Contains("$status"))) + { + cmd.Parameters.AddWithValue("$status", status.Trim()); + } + if (where.Any(w => w.Contains("$persona"))) + { + cmd.Parameters.AddWithValue("$persona", persona.Trim()); + } + if (where.Any(w => w.Contains("$ds"))) + { + cmd.Parameters.AddWithValue("$ds", datasetId.Trim()); + } + cmd.Parameters.AddWithValue("$lim", take); + List list = []; + using SqliteDataReader r = cmd.ExecuteReader(); + while (r.Read()) + { + list.Add(ReadTrainSampleRow(r)); + } + return list; + } + } + + static JObject ReadTrainSampleRow(SqliteDataReader r) + { + JArray messages = []; + try + { + messages = JArray.Parse(r.GetString(7)); + } + catch + { + // ignore + } + bool hasAgentLinked = r.FieldCount > 11; + return new JObject + { + ["id"] = r.GetString(0), + ["dataset_id"] = r.GetString(1), + ["source"] = r.GetString(2), + ["chat_id"] = r.IsDBNull(3) ? null : r.GetString(3), + ["persona"] = r.IsDBNull(4) ? null : r.GetString(4), + ["pack"] = r.IsDBNull(5) ? null : r.GetString(5), + ["hf_repo"] = r.IsDBNull(6) ? null : r.GetString(6), + ["messages"] = messages, + ["status"] = r.GetString(8), + ["createdAt"] = r.GetInt64(9), + ["updatedAt"] = r.GetInt64(10), + ["agent_linked"] = hasAgentLinked && !r.IsDBNull(11) && r.GetInt64(11) != 0, + }; + } + + public JObject GetTrainSample(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + lock (_lock) + { + EnsureOpen(); + bool hasAgentLinked = HasColumn("train_samples", "agent_linked"); + string sql = hasAgentLinked + ? "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at, agent_linked FROM train_samples WHERE id = $id LIMIT 1" + : "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at FROM train_samples WHERE id = $id LIMIT 1"; + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = sql; + cmd.Parameters.AddWithValue("$id", id.Trim()); + using SqliteDataReader r = cmd.ExecuteReader(); + return r.Read() ? ReadTrainSampleRow(r) : null; + } + } + + public JObject UpsertTrainSample(JObject sample) + { + lock (_lock) + { + EnsureOpen(); + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + string id = sample["id"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + id = $"ts_{now}_{Guid.NewGuid():N}"[..24]; + } + string datasetId = sample["dataset_id"]?.ToString()?.Trim() ?? "default"; + JArray messages = sample["messages"] as JArray ?? []; + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO train_samples(id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at) + VALUES($id, $ds, $src, $chat, $persona, $pack, $hf, $msg, $status, $c, $u) + ON CONFLICT(id) DO UPDATE SET + dataset_id = excluded.dataset_id, + source = excluded.source, + chat_id = excluded.chat_id, + persona = excluded.persona, + pack = excluded.pack, + hf_repo = excluded.hf_repo, + messages_json = excluded.messages_json, + status = excluded.status, + updated_at = excluded.updated_at + """; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$ds", datasetId); + cmd.Parameters.AddWithValue("$src", sample["source"]?.ToString() ?? "manual"); + cmd.Parameters.AddWithValue("$chat", (object)sample["chat_id"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$persona", (object)sample["persona"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$pack", (object)sample["pack"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$hf", (object)sample["hf_repo"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$msg", messages.ToString(Newtonsoft.Json.Formatting.None)); + cmd.Parameters.AddWithValue("$status", sample["status"]?.ToString() ?? "draft"); + long created = sample["createdAt"]?.Value() ?? now; + cmd.Parameters.AddWithValue("$c", created); + cmd.Parameters.AddWithValue("$u", now); + cmd.ExecuteNonQuery(); + return new JObject { ["id"] = id, ["updatedAt"] = now }; + } + } + + public bool DeleteTrainSample(string id) + { + lock (_lock) + { + EnsureOpen(); + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "DELETE FROM train_samples WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id ?? ""); + return cmd.ExecuteNonQuery() > 0; + } + } + + public int CountTrainSamples(string status = null) + { + lock (_lock) + { + EnsureOpen(); + using SqliteCommand cmd = _conn.CreateCommand(); + if (string.IsNullOrWhiteSpace(status) || string.Equals(status, "all", StringComparison.OrdinalIgnoreCase)) + { + cmd.CommandText = "SELECT COUNT(*) FROM train_samples"; + } + else + { + cmd.CommandText = "SELECT COUNT(*) FROM train_samples WHERE status = $s"; + cmd.Parameters.AddWithValue("$s", status.Trim()); + } + return Convert.ToInt32(cmd.ExecuteScalar()); + } + } + + public JObject SaveTrainJob(JObject job) + { + lock (_lock) + { + EnsureOpen(); + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + string id = job["id"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(id)) + { + id = $"tj_{now}_{Guid.NewGuid():N}"[..24]; + } + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = + """ + INSERT INTO train_jobs(id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at) + VALUES($id, $kind, $status, $cfg, $base, $out, $log, $prog, $c, $u, $f) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + config_json = excluded.config_json, + log_path = excluded.log_path, + progress_json = excluded.progress_json, + updated_at = excluded.updated_at, + finished_at = excluded.finished_at + """; + cmd.Parameters.AddWithValue("$id", id); + cmd.Parameters.AddWithValue("$kind", job["kind"]?.ToString() ?? "qlora"); + cmd.Parameters.AddWithValue("$status", job["status"]?.ToString() ?? "pending"); + cmd.Parameters.AddWithValue("$cfg", job["config"]?.ToString(Newtonsoft.Json.Formatting.None) ?? job["config_json"]?.ToString() ?? "{}"); + cmd.Parameters.AddWithValue("$base", (object)job["base_model"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$out", (object)job["output_name"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$log", (object)job["log_path"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$prog", (object)job["progress"]?.ToString(Newtonsoft.Json.Formatting.None) ?? job["progress_json"]?.ToString() ?? DBNull.Value); + cmd.Parameters.AddWithValue("$c", job["created_at"]?.Value() ?? job["createdAt"]?.Value() ?? now); + cmd.Parameters.AddWithValue("$u", now); + cmd.Parameters.AddWithValue("$f", (object)(job["finished_at"]?.Value() ?? job["finishedAt"]?.Value()) ?? DBNull.Value); + cmd.ExecuteNonQuery(); + return new JObject { ["id"] = id }; + } + } + + public JObject GetTrainJob(string id) + { + lock (_lock) + { + EnsureOpen(); + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at FROM train_jobs WHERE id = $id"; + cmd.Parameters.AddWithValue("$id", id ?? ""); + using SqliteDataReader r = cmd.ExecuteReader(); + if (!r.Read()) + { + return null; + } + return new JObject + { + ["id"] = r.GetString(0), + ["kind"] = r.GetString(1), + ["status"] = r.GetString(2), + ["config_json"] = r.IsDBNull(3) ? null : r.GetString(3), + ["base_model"] = r.IsDBNull(4) ? null : r.GetString(4), + ["output_name"] = r.IsDBNull(5) ? null : r.GetString(5), + ["log_path"] = r.IsDBNull(6) ? null : r.GetString(6), + ["progress_json"] = r.IsDBNull(7) ? null : r.GetString(7), + ["created_at"] = r.GetInt64(8), + ["updated_at"] = r.GetInt64(9), + ["finished_at"] = r.IsDBNull(10) ? null : r.GetInt64(10), + }; + } + } + + public JObject GetActiveTrainJob() + { + lock (_lock) + { + EnsureOpen(); + using SqliteCommand cmd = _conn.CreateCommand(); + cmd.CommandText = "SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at FROM train_jobs WHERE status IN ('pending','running') ORDER BY updated_at DESC LIMIT 1"; + using SqliteDataReader r = cmd.ExecuteReader(); + if (!r.Read()) + { + return null; + } + return new JObject + { + ["id"] = r.GetString(0), + ["kind"] = r.GetString(1), + ["status"] = r.GetString(2), + ["config_json"] = r.IsDBNull(3) ? null : r.GetString(3), + ["base_model"] = r.IsDBNull(4) ? null : r.GetString(4), + ["output_name"] = r.IsDBNull(5) ? null : r.GetString(5), + ["log_path"] = r.IsDBNull(6) ? null : r.GetString(6), + ["progress_json"] = r.IsDBNull(7) ? null : r.GetString(7), + ["created_at"] = r.GetInt64(8), + ["updated_at"] = r.GetInt64(9), + ["finished_at"] = r.IsDBNull(10) ? null : r.GetInt64(10), + }; + } + } +} diff --git a/AssistentMemory.cs b/AssistentMemory.cs index 6604ff9..46597da 100644 --- a/AssistentMemory.cs +++ b/AssistentMemory.cs @@ -37,8 +37,12 @@ public sealed partial class AssistentMemory : IDisposable ["note"] = 4, ["model"] = 2, ["aspect"] = 1, + ["heard"] = 3, }; + public static Dictionary CopyDefaultQuotas() + => new(DefaultQuotas, StringComparer.OrdinalIgnoreCase); + readonly string _dataRoot; readonly string _dbPath; readonly HttpClient _http; @@ -139,6 +143,14 @@ public sealed partial class AssistentMemory : IDisposable Logs.Debug($"AssistentMemory store schema: {ex.Message}"); } try + { + EnsureTrainingSchema(); + } + catch (Exception ex) + { + Logs.Debug($"AssistentMemory training schema: {ex.Message}"); + } + try { EnsureUserPrefsSchema(); } @@ -857,7 +869,7 @@ public sealed partial class AssistentMemory : IDisposable { EnsureOpen(); using SqliteCommand cmd = _conn.CreateCommand(); - cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories WHERE kind = $kind AND key = $key"; + cmd.CommandText = "SELECT kind, key, text, source, persona, updated, meta_json FROM memories WHERE kind = $kind AND key = $key"; cmd.Parameters.AddWithValue("$kind", kind); cmd.Parameters.AddWithValue("$key", key); using SqliteDataReader reader = cmd.ExecuteReader(); @@ -885,6 +897,7 @@ public sealed partial class AssistentMemory : IDisposable ["scope"] = shared ? "shared" : "personal", ["persona"] = shared ? "shared" : persona, ["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5), + ["meta_json"] = reader.IsDBNull(6) ? null : reader.GetString(6), }; } } diff --git a/AssistentPatch.cs b/AssistentPatch.cs index 0f3fe1e..9485a51 100644 --- a/AssistentPatch.cs +++ b/AssistentPatch.cs @@ -186,6 +186,7 @@ public partial class SwarmAssistentExtension || s.Equals("persona_read", StringComparison.OrdinalIgnoreCase) || s.Equals("memory_get", StringComparison.OrdinalIgnoreCase) || s.Equals("memory_search", StringComparison.OrdinalIgnoreCase) + || s.Equals("heard_search", StringComparison.OrdinalIgnoreCase) || s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase) || s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase) || s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase) @@ -277,6 +278,11 @@ public partial class SwarmAssistentExtension { return "memory_search"; } + if ((ActionsContain(patch, "heard_search") || string.Equals(patch["heard_query"]?.ToString(), "1", StringComparison.Ordinal)) + && !Skip("heard_search")) + { + return "heard_search"; + } if ((ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString())) && !Skip("lookup_tags")) { diff --git a/AssistentPersist.cs b/AssistentPersist.cs index 1a3e550..5b6cce7 100644 --- a/AssistentPersist.cs +++ b/AssistentPersist.cs @@ -21,7 +21,7 @@ public partial class SwarmAssistentExtension 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", "park_llm", + "auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab", "park_llm", "chats_drawer", ]; static string SafeChatId(string id) diff --git a/AssistentTraining.Agent.cs b/AssistentTraining.Agent.cs new file mode 100644 index 0000000..21418f4 --- /dev/null +++ b/AssistentTraining.Agent.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using SwarmUI.Accounts; +using SwarmUI.Utils; + +namespace Mrleo1nid.SwarmAssistent; + +/// Link training dataset samples to the live agent as retrievable "heard" examples. +public partial class SwarmAssistentExtension +{ + static string MemoryEmbedForTraining(string personaId = null) + { + string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId(); + return Config.LoadSettings()["embed_model"]?.ToString() + ?? Config.LoadAssistant(pid)["embed_model"]?.ToString() + ?? "nomic-embed-text"; + } + + static string MemoryBaseForTraining(JObject raw = null) + => MemoryBaseUrl(raw?["base_url"]?.ToString()); + + public async Task AssistentGetDatasetAgentSettings(Session session) + { + await Task.CompletedTask; + JObject settings = Config.LoadTrainingAgent(); + return new JObject + { + ["success"] = true, + ["settings"] = settings, + ["linked"] = Memory.CountAgentLinkedTrainSamples(), + ["approved"] = Memory.CountTrainSamples("approved"), + }; + } + + public async Task AssistentSaveDatasetAgentSettings(Session session, JObject settings) + { + await Task.CompletedTask; + if (settings is null) + { + return new JObject { ["error"] = "settings required" }; + } + Config.SaveTrainingAgent(settings); + return new JObject { ["success"] = true, ["settings"] = Config.LoadTrainingAgent() }; + } + + public async Task AssistentLinkTrainSampleToAgent(Session session, string id, JObject raw = null) + { + if (string.IsNullOrWhiteSpace(id)) + { + return new JObject { ["error"] = "id required" }; + } + JObject sample = Memory.GetTrainSample(id.Trim()); + if (sample is null) + { + return new JObject { ["error"] = "sample not found" }; + } + if (!string.Equals(sample["status"]?.ToString(), "approved", StringComparison.OrdinalIgnoreCase)) + { + return new JObject { ["error"] = "only approved samples can be linked to the agent" }; + } + try + { + string embed = MemoryEmbedForTraining(sample["persona"]?.ToString()); + bool ok = await Memory.LinkTrainSampleToAgentAsync(MemoryBaseForTraining(raw), sample, embed); + return new JObject { ["success"] = ok, ["linked"] = Memory.CountAgentLinkedTrainSamples() }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message }; + } + } + + public async Task AssistentUnlinkTrainSampleFromAgent(Session session, string id) + { + await Task.CompletedTask; + if (string.IsNullOrWhiteSpace(id)) + { + return new JObject { ["error"] = "id required" }; + } + JObject sample = Memory.GetTrainSample(id.Trim()); + if (sample is null) + { + return new JObject { ["error"] = "sample not found" }; + } + Memory.UnlinkTrainSampleFromAgent(sample); + return new JObject { ["success"] = true, ["linked"] = Memory.CountAgentLinkedTrainSamples() }; + } + + public async Task AssistentSyncDatasetToAgent(Session session, JObject raw = null) + { + bool approvedOnly = raw?["approved_only"]?.Value() ?? true; + bool relink = raw?["relink"]?.Value() ?? false; + string persona = raw?["persona"]?.ToString(); + string status = approvedOnly ? "approved" : "all"; + List samples = Memory.ListTrainSamples(status, persona, null, 2000); + string baseUrl = MemoryBaseForTraining(raw); + int linked = 0; + int skipped = 0; + List errors = []; + foreach (JObject sample in samples) + { + bool already = sample["agent_linked"]?.Value() ?? false; + if (already && !relink) + { + skipped++; + continue; + } + if (!string.Equals(sample["status"]?.ToString(), "approved", StringComparison.OrdinalIgnoreCase)) + { + skipped++; + continue; + } + try + { + string embed = MemoryEmbedForTraining(sample["persona"]?.ToString()); + if (await Memory.LinkTrainSampleToAgentAsync(baseUrl, sample, embed)) + { + linked++; + } + else + { + skipped++; + } + } + catch (Exception ex) + { + errors.Add($"{sample["id"]}: {ex.Message}"); + } + } + return new JObject + { + ["success"] = true, + ["linked_now"] = linked, + ["skipped"] = skipped, + ["total_linked"] = Memory.CountAgentLinkedTrainSamples(), + ["errors"] = new JArray(errors.Take(8)), + }; + } + + async Task TryAutoLinkTrainSample(Session session, JObject sample, JObject raw = null) + { + if (sample is null || Memory is null) + { + return; + } + JObject agent = Config.LoadTrainingAgent(); + if (agent["enabled"]?.Value() == false) + { + return; + } + if (agent["auto_link_on_approve"]?.Value() == false) + { + return; + } + string status = sample["status"]?.ToString() ?? ""; + if (!string.Equals(status, "approved", StringComparison.OrdinalIgnoreCase)) + { + Memory.UnlinkTrainSampleFromAgent(sample); + return; + } + try + { + string embed = MemoryEmbedForTraining(sample["persona"]?.ToString()); + await Memory.LinkTrainSampleToAgentAsync(MemoryBaseForTraining(raw), sample, embed); + } + catch (Exception ex) + { + Logs.Debug($"TryAutoLinkTrainSample: {ex.Message}"); + } + } +} diff --git a/AssistentTraining.cs b/AssistentTraining.cs new file mode 100644 index 0000000..38feac1 --- /dev/null +++ b/AssistentTraining.cs @@ -0,0 +1,443 @@ +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; + +/// Training samples, dataset import/export, Ollama Modelfile builder. +public partial class SwarmAssistentExtension +{ + static string TrainingRoot() + { + string root = Path.Combine(DataRoot(), "Assistent", "training"); + Directory.CreateDirectory(root); + Directory.CreateDirectory(Path.Combine(root, "datasets")); + Directory.CreateDirectory(Path.Combine(root, "jobs")); + Directory.CreateDirectory(Path.Combine(root, "adapters")); + return root; + } + + public async Task AssistentListTrainSamples(Session session, string status = null, string persona = null, int limit = 200) + { + await Task.CompletedTask; + try + { + List list = Memory.ListTrainSamples(status, persona, null, limit); + return new JObject + { + ["success"] = true, + ["samples"] = new JArray(list), + ["approved"] = Memory.CountTrainSamples("approved"), + ["total"] = Memory.CountTrainSamples(null), + }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message }; + } + } + + public async Task AssistentUpsertTrainSample(Session session, JObject raw) + { + await Task.CompletedTask; + if (raw is null) + { + return new JObject { ["error"] = "body required" }; + } + try + { + JObject saved = Memory.UpsertTrainSample(raw); + JObject full = Memory.GetTrainSample(saved["id"]?.ToString()) ?? raw; + await TryAutoLinkTrainSample(session, full, raw); + return new JObject { ["success"] = true, ["sample"] = full }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message }; + } + } + + public async Task AssistentDeleteTrainSample(Session session, string id) + { + await Task.CompletedTask; + if (string.IsNullOrWhiteSpace(id)) + { + return new JObject { ["error"] = "id required" }; + } + JObject sample = Memory.GetTrainSample(id.Trim()); + bool ok = Memory.DeleteTrainSample(id.Trim()); + if (ok && sample is not null) + { + Memory.UnlinkTrainSampleFromAgent(sample); + } + return new JObject { ["success"] = true, ["deleted"] = ok }; + } + + public async Task AssistentBuildDatasetFromChats(Session session, bool approved_only = false) + { + await Task.CompletedTask; + try + { + List chats = Memory.ListChats(withMessages: true, limit: AssistentMemory.MaxChatsStored); + int added = 0; + foreach (JObject chat in chats) + { + JArray messages = chat["messages"] as JArray ?? []; + for (int i = 0; i < messages.Count - 1; i++) + { + if (messages[i] is not JObject u || messages[i + 1] is not JObject a) + { + continue; + } + if (!string.Equals(u["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (!string.Equals(a["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + Memory.UpsertTrainSample(new JObject + { + ["source"] = "chat", + ["chat_id"] = chat["id"], + ["persona"] = a["persona"] ?? u["persona"], + ["pack"] = a["pack"] ?? u["pack"], + ["status"] = approved_only ? "approved" : "draft", + ["messages"] = new JArray { u.DeepClone(), a.DeepClone() }, + }); + added++; + } + } + return new JObject { ["success"] = true, ["added"] = added }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message }; + } + } + + public async Task AssistentImportDataset(Session session, JObject raw) + { + await Task.CompletedTask; + string content = raw?["content"]?.ToString(); + string format = raw?["format"]?.ToString() ?? "auto"; + if (string.IsNullOrWhiteSpace(content)) + { + return new JObject { ["error"] = "content required" }; + } + try + { + int imported = 0; + string fmt = (format ?? "auto").Trim().ToLowerInvariant(); + List records = ParseDatasetContent(content, fmt); + foreach (JObject rec in records) + { + JArray messages = rec["messages"] as JArray; + if (messages is null || messages.Count == 0) + { + continue; + } + Memory.UpsertTrainSample(new JObject + { + ["source"] = "import", + ["messages"] = messages, + ["status"] = "draft", + }); + imported++; + } + return new JObject { ["success"] = true, ["imported"] = imported }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message }; + } + } + + static List ParseDatasetContent(string content, string format) + { + List list = []; + string trimmed = content.Trim(); + if (trimmed.StartsWith('[')) + { + JArray arr = JArray.Parse(trimmed); + foreach (JToken t in arr) + { + if (t is JObject o) + { + JArray msgs = ExtractMessagesFromRecord(o); + if (msgs != null) + { + list.Add(new JObject { ["messages"] = msgs }); + } + } + } + return list; + } + if (format == "csv" || LooksLikeCsv(trimmed)) + { + return ParseCsvDataset(trimmed); + } + foreach (string line in trimmed.Split('\n')) + { + string ln = line.Trim(); + if (string.IsNullOrWhiteSpace(ln)) + { + continue; + } + try + { + JObject o = JObject.Parse(ln); + JArray msgs = ExtractMessagesFromRecord(o); + if (msgs != null) + { + list.Add(new JObject { ["messages"] = msgs }); + } + } + catch + { + // skip bad line + } + } + return list; + } + + static bool LooksLikeCsv(string s) => s.Contains(',') && s.Contains('\n') && !s.TrimStart().StartsWith('{'); + + static List ParseCsvDataset(string csv) + { + List list = []; + string[] lines = csv.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray(); + if (lines.Length < 2) + { + return list; + } + string[] headers = lines[0].Split(',').Select(h => h.Trim().Trim('"')).ToArray(); + int promptIdx = Array.FindIndex(headers, h => h.Equals("prompt", StringComparison.OrdinalIgnoreCase) || h.Equals("question", StringComparison.OrdinalIgnoreCase) || h.Equals("instruction", StringComparison.OrdinalIgnoreCase)); + int respIdx = Array.FindIndex(headers, h => h.Equals("response", StringComparison.OrdinalIgnoreCase) || h.Equals("answer", StringComparison.OrdinalIgnoreCase) || h.Equals("output", StringComparison.OrdinalIgnoreCase) || h.Equals("completion", StringComparison.OrdinalIgnoreCase)); + if (promptIdx < 0 || respIdx < 0) + { + return list; + } + for (int i = 1; i < lines.Length; i++) + { + string[] cols = SplitCsvLine(lines[i]); + if (cols.Length <= Math.Max(promptIdx, respIdx)) + { + continue; + } + list.Add(new JObject + { + ["messages"] = new JArray + { + new JObject { ["role"] = "user", ["content"] = cols[promptIdx] }, + new JObject { ["role"] = "assistant", ["content"] = cols[respIdx] }, + }, + }); + } + return list; + } + + static string[] SplitCsvLine(string line) + { + List parts = []; + StringBuilder cur = new(); + bool inQ = false; + foreach (char c in line) + { + if (c == '"') + { + inQ = !inQ; + continue; + } + if (c == ',' && !inQ) + { + parts.Add(cur.ToString().Trim()); + cur.Clear(); + continue; + } + cur.Append(c); + } + parts.Add(cur.ToString().Trim()); + return parts.ToArray(); + } + + static JArray ExtractMessagesFromRecord(JObject o) + { + if (o["messages"] is JArray msgs) + { + return NormalizeMessagesArray(msgs); + } + if (o["conversations"] is JArray conv) + { + return ConvertHfRowToMessages(new JObject { ["conversations"] = conv }, new JObject { ["kind"] = "conversations" }, null); + } + if (o["instruction"] != null && o["output"] != null) + { + return ConvertHfRowToMessages(o, new JObject { ["kind"] = "alpaca" }, null); + } + if (o["prompt"] != null && (o["response"] != null || o["completion"] != null)) + { + return ConvertHfRowToMessages(o, new JObject { ["kind"] = "prompt_response", ["response_col"] = o["response"] != null ? "response" : "completion" }, null); + } + return null; + } + + public async Task AssistentExportDataset(Session session, string status = "approved", string format = "jsonl") + { + await Task.CompletedTask; + try + { + List samples = Memory.ListTrainSamples(status, null, null, 5000); + StringBuilder sb = new(); + foreach (JObject s in samples) + { + JArray messages = s["messages"] as JArray ?? []; + if (messages.Count == 0) + { + continue; + } + if (string.Equals(format, "sharegpt", StringComparison.OrdinalIgnoreCase)) + { + JArray conv = []; + foreach (JToken m in messages) + { + if (m is not JObject mo) + { + continue; + } + string role = mo["role"]?.ToString() ?? "user"; + conv.Add(new JObject + { + ["from"] = role == "assistant" ? "gpt" : "human", + ["value"] = mo["content"]?.ToString() ?? "", + }); + } + sb.AppendLine(new JObject { ["conversations"] = conv }.ToString(Newtonsoft.Json.Formatting.None)); + } + else + { + sb.AppendLine(new JObject { ["messages"] = messages }.ToString(Newtonsoft.Json.Formatting.None)); + } + } + string path = Path.Combine(TrainingRoot(), "datasets", $"export_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.jsonl"); + await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8); + return new JObject { ["success"] = true, ["path"] = path, ["count"] = samples.Count, ["content"] = sb.ToString() }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message }; + } + } + + public async Task AssistentCreateOllamaModel(Session session, JObject raw) + { + if (raw is null) + { + return new JObject { ["error"] = "body required" }; + } + string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString()); + string baseModel = raw["base_model"]?.ToString()?.Trim(); + string name = raw["name"]?.ToString()?.Trim(); + string system = raw["system"]?.ToString() ?? ""; + int shots = raw["shots"]?.Value() ?? 8; + if (string.IsNullOrWhiteSpace(baseModel) || string.IsNullOrWhiteSpace(name)) + { + return new JObject { ["error"] = "base_model and name required" }; + } + if (string.IsNullOrWhiteSpace(system)) + { + string persona = AssistentConfig.SafeId(raw["persona"]?.ToString()) ?? Config.DefaultPersonaId(); + system = Config.LoadCorePrompt(persona) + "\n\n" + Config.RenderIdentityBlock(persona, includeAllShelves: true); + } + StringBuilder mf = new(); + mf.AppendLine($"FROM {baseModel}"); + mf.AppendLine($"SYSTEM \"\"\"{system}\"\"\""); + List samples = Memory.ListTrainSamples("approved", null, null, Math.Clamp(shots, 0, 32)); + foreach (JObject s in samples.Take(shots)) + { + JArray messages = s["messages"] as JArray ?? []; + foreach (JToken m in messages) + { + if (m is not JObject mo) + { + continue; + } + string role = mo["role"]?.ToString() ?? "user"; + string content = mo["content"]?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(content)) + { + continue; + } + mf.AppendLine($"MESSAGE {role} \"\"\"{content.Replace("\"\"\"", "\"\"\"\"\"\"\"")}\"\"\""); + } + } + if (raw["num_ctx"] != null) + { + mf.AppendLine($"PARAMETER num_ctx {raw["num_ctx"]}"); + } + if (raw["temperature"] != null) + { + mf.AppendLine($"PARAMETER temperature {raw["temperature"]}"); + } + string modelfilePath = Path.Combine(TrainingRoot(), "jobs", $"modelfile_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.Modelfile"); + Directory.CreateDirectory(Path.GetDirectoryName(modelfilePath)!); + await File.WriteAllTextAsync(modelfilePath, mf.ToString(), Encoding.UTF8); + try + { + JObject payload = new() + { + ["name"] = name, + ["modelfile"] = mf.ToString(), + ["stream"] = false, + }; + using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); + using HttpResponseMessage resp = await HttpClient.PostAsync($"{baseUrl}/api/create", content); + string body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + return new JObject { ["error"] = $"Ollama create HTTP {(int)resp.StatusCode}: {Clip(body, 400)}", ["modelfile_path"] = modelfilePath }; + } + return new JObject { ["success"] = true, ["name"] = name, ["modelfile_path"] = modelfilePath, ["ollama"] = body }; + } + catch (Exception ex) + { + return new JObject { ["error"] = ex.Message, ["modelfile_path"] = modelfilePath }; + } + } + + public async Task AssistentGetTrainJob(Session session, string id = null) + { + await Task.CompletedTask; + JObject job = string.IsNullOrWhiteSpace(id) ? Memory.GetActiveTrainJob() : Memory.GetTrainJob(id); + return new JObject + { + ["success"] = true, + ["job"] = job, + ["training_active"] = TrainingJobManager.IsRunning, + }; + } + + public async Task AssistentSaveRunnerSettings(Session session, JObject settings) + { + await Task.CompletedTask; + if (settings is null) + { + return new JObject { ["error"] = "settings required" }; + } + Config.SaveTrainingRunner(settings); + return new JObject { ["success"] = true }; + } + + public async Task AssistentGetRunnerSettings(Session session) + { + await Task.CompletedTask; + return new JObject { ["success"] = true, ["settings"] = Config.LoadTrainingRunner() }; + } +} diff --git a/AssistentTrainingJobs.cs b/AssistentTrainingJobs.cs new file mode 100644 index 0000000..0b0ae18 --- /dev/null +++ b/AssistentTrainingJobs.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using SwarmUI.Accounts; +using SwarmUI.Utils; +using SwarmUI.WebAPI; + +namespace Mrleo1nid.SwarmAssistent; + +/// QLoRA training job runner with VRAM lock and progress streaming. +public partial class SwarmAssistentExtension +{ + static readonly TrainingJobManager TrainingJobManager = new(); + + public async Task AssistentStartTrainJob(Session session, JObject raw) + { + if (raw is null) + { + return new JObject { ["error"] = "body required" }; + } + if (TrainingJobManager.IsRunning) + { + return new JObject { ["error"] = "Тренировка уже идёт" }; + } + string hfBase = raw["base_model"]?.ToString()?.Trim(); + string outputName = raw["output_name"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(hfBase) || string.IsNullOrWhiteSpace(outputName)) + { + return new JObject { ["error"] = "base_model and output_name required" }; + } + if (!string.IsNullOrWhiteSpace(raw["hf_dataset"]?.ToString())) + { + string dsId = NormalizeHfDatasetId(raw["hf_dataset"]?.ToString()); + if (dsId is null) + { + return new JObject { ["error"] = "invalid hf_dataset id" }; + } + JObject check = await CheckHfDatasetInternal(session, dsId, useCache: true); + if (check["gate"]?.ToString() == "rejected") + { + return new JObject { ["error"] = check["reason"]?.ToString() ?? "hf dataset rejected" }; + } + raw["hf_dataset"] = dsId; + } + JObject runner = Config.LoadTrainingRunner(); + string python = runner["python"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(python)) + { + python = "python"; + } + string kind = runner["kind"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(kind)) + { + return new JObject { ["error"] = "QLoRA-раннер не настроен (Настройки → Модели)" }; + } + string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString()); + string chatModel = raw["chat_model"]?.ToString()?.Trim(); + if (!string.IsNullOrWhiteSpace(chatModel)) + { + await AssistentParkLlm(session, baseUrl, chatModel); + } + JObject export = await AssistentExportDataset(session, "approved", "jsonl"); + string datasetPath = export["path"]?.ToString(); + if (string.IsNullOrWhiteSpace(datasetPath) || !File.Exists(datasetPath)) + { + return new JObject { ["error"] = "Нет одобренных примеров для тренировки" }; + } + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + string jobId = $"tj_{now}"; + string jobDir = Path.Combine(TrainingRoot(), "jobs", jobId); + Directory.CreateDirectory(jobDir); + string configPath = Path.Combine(jobDir, "config.json"); + string logPath = Path.Combine(jobDir, "log.txt"); + JObject jobConfig = new() + { + ["base_model"] = hfBase, + ["output_name"] = outputName, + ["dataset_path"] = datasetPath, + ["hf_dataset"] = raw["hf_dataset"], + ["rank"] = raw["rank"] ?? 16, + ["alpha"] = raw["alpha"] ?? 32, + ["lr"] = raw["lr"] ?? 0.0002, + ["epochs"] = raw["epochs"] ?? 3, + ["seq_len"] = raw["seq_len"] ?? 2048, + ["four_bit"] = raw["four_bit"] ?? true, + ["adapter_dir"] = Path.Combine(TrainingRoot(), "adapters", outputName), + }; + await File.WriteAllTextAsync(configPath, jobConfig.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); + Memory.SaveTrainJob(new JObject + { + ["id"] = jobId, + ["kind"] = "qlora", + ["status"] = "running", + ["config"] = jobConfig, + ["base_model"] = hfBase, + ["output_name"] = outputName, + ["log_path"] = logPath, + ["created_at"] = now, + }); + string cmdLine = BuildRunnerCommand(runner, configPath, logPath, jobDir); + bool started = TrainingJobManager.Start(this, session, jobId, cmdLine, logPath, baseUrl, chatModel, GetHfToken(session)); + if (!started) + { + Memory.SaveTrainJob(new JObject { ["id"] = jobId, ["status"] = "failed", ["progress"] = new JObject { ["error"] = "process start failed" } }); + return new JObject { ["error"] = "Не удалось запустить процесс тренировки" }; + } + return new JObject { ["success"] = true, ["job_id"] = jobId, ["log_path"] = logPath }; + } + + static string BuildRunnerCommand(JObject runner, string configPath, string logPath, string workDir) + { + string python = runner["python"]?.ToString()?.Trim(); + if (string.IsNullOrWhiteSpace(python)) + { + python = "python"; + } + string kind = runner["kind"]?.ToString()?.Trim() ?? "custom"; + string custom = runner["cmd"]?.ToString()?.Trim(); + string scriptPath = Path.Combine(FilePath, "scripts", "train_qlora.py"); + if (kind == "custom" && !string.IsNullOrWhiteSpace(custom)) + { + return custom + .Replace("{python}", python, StringComparison.OrdinalIgnoreCase) + .Replace("{config}", configPath, StringComparison.OrdinalIgnoreCase) + .Replace("{log}", logPath, StringComparison.OrdinalIgnoreCase) + .Replace("{workdir}", workDir, StringComparison.OrdinalIgnoreCase); + } + return $"\"{python}\" \"{scriptPath}\" --config \"{configPath}\" --log \"{logPath}\""; + } + + public async Task AssistentCancelTrainJob(Session session, string id = null) + { + await Task.CompletedTask; + TrainingJobManager.Cancel(); + string jobId = id ?? TrainingJobManager.CurrentJobId; + if (!string.IsNullOrWhiteSpace(jobId)) + { + Memory.SaveTrainJob(new JObject + { + ["id"] = jobId, + ["status"] = "cancelled", + ["finished_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }); + } + return new JObject { ["success"] = true, ["cancelled"] = true }; + } + + public async Task AssistentTrainWS(Session session, WebSocket ws, JObject raw) + { + await Task.CompletedTask; + try + { + while (TrainingJobManager.IsRunning && ws.State == System.Net.WebSockets.WebSocketState.Open) + { + JObject progress = TrainingJobManager.GetProgress(); + string msg = progress.ToString(Newtonsoft.Json.Formatting.None); + await ws.SendAsync(Encoding.UTF8.GetBytes(msg), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None); + await Task.Delay(800); + } + JObject final = TrainingJobManager.GetProgress(); + final["done"] = true; + await ws.SendAsync(Encoding.UTF8.GetBytes(final.ToString(Newtonsoft.Json.Formatting.None)), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None); + } + catch (Exception ex) + { + Logs.Debug($"AssistentTrainWS: {ex.Message}"); + } + return new JObject { ["success"] = true }; + } + + internal async Task FinishTrainJobAsync(string jobId, bool success, string logPath, Session session, string baseUrl, string chatModel, string adapterDir, string outputName, string ggufScript) + { + long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + Memory.SaveTrainJob(new JObject + { + ["id"] = jobId, + ["status"] = success ? "completed" : "failed", + ["finished_at"] = now, + ["progress"] = TrainingJobManager.GetProgress(), + }); + if (success && Directory.Exists(adapterDir)) + { + try + { + await RegisterAdapterInOllama(session, baseUrl, outputName, adapterDir, ggufScript); + } + catch (Exception ex) + { + Logs.Debug($"RegisterAdapter: {ex.Message}"); + } + } + if (!string.IsNullOrWhiteSpace(chatModel)) + { + await AssistentWarmLlm(session, baseUrl, chatModel); + } + TrainingJobManager.ClearRunning(); + } + + async Task RegisterAdapterInOllama(Session session, string baseUrl, string outputName, string adapterDir, string ggufScript) + { + string adapterFile = Directory.GetFiles(adapterDir, "*.gguf").FirstOrDefault() + ?? Directory.GetFiles(adapterDir, "adapter_model.safetensors").FirstOrDefault(); + if (string.IsNullOrWhiteSpace(adapterFile)) + { + return; + } + StringBuilder mf = new(); + JObject job = Memory.GetTrainJob(TrainingJobManager.CurrentJobId ?? ""); + string baseModel = job?["base_model"]?.ToString() ?? "unknown"; + mf.AppendLine($"FROM {baseModel}"); + mf.AppendLine($"ADAPTER {adapterFile.Replace("\\", "/")}"); + JObject payload = new() + { + ["name"] = outputName, + ["modelfile"] = mf.ToString(), + ["stream"] = false, + }; + using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); + using HttpResponseMessage resp = await HttpClient.PostAsync($"{NormalizeBaseUrl(baseUrl)}/api/create", content); + _ = await resp.Content.ReadAsStringAsync(); + } +} + +sealed class TrainingJobManager +{ + static readonly Regex LossRe = new(@"loss[:\s]+([0-9.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + static readonly Regex StepRe = new(@"(\d+)\s*/\s*(\d+)", RegexOptions.Compiled); + + Process _process; + readonly object _lock = new(); + JObject _progress = new() { ["status"] = "idle" }; + string _logPath; + SwarmAssistentExtension _ext; + Session _session; + string _jobId; + string _baseUrl; + string _chatModel; + + public bool IsRunning { get; private set; } + public string CurrentJobId => _jobId; + + public bool Start(SwarmAssistentExtension ext, Session session, string jobId, string commandLine, string logPath, string baseUrl, string chatModel, string hfToken) + { + lock (_lock) + { + if (IsRunning) + { + return false; + } + _ext = ext; + _session = session; + _jobId = jobId; + _logPath = logPath; + _baseUrl = baseUrl; + _chatModel = chatModel; + _progress = new JObject { ["status"] = "running", ["step"] = 0, ["loss"] = null, ["log"] = "" }; + try + { + ProcessStartInfo psi = new() + { + FileName = "cmd.exe", + Arguments = $"/c {commandLine}", + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + WorkingDirectory = Path.GetDirectoryName(logPath) ?? Environment.CurrentDirectory, + }; + if (!string.IsNullOrWhiteSpace(hfToken)) + { + psi.Environment["HF_TOKEN"] = hfToken; + } + _process = new Process { StartInfo = psi, EnableRaisingEvents = true }; + _process.OutputDataReceived += (_, e) => AppendLog(e.Data); + _process.ErrorDataReceived += (_, e) => AppendLog(e.Data); + _process.Exited += async (_, _) => await OnExited(); + _process.Start(); + _process.BeginOutputReadLine(); + _process.BeginErrorReadLine(); + IsRunning = true; + return true; + } + catch (Exception ex) + { + _progress["error"] = ex.Message; + IsRunning = false; + return false; + } + } + } + + void AppendLog(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return; + } + lock (_lock) + { + try + { + File.AppendAllText(_logPath, line + Environment.NewLine); + } + catch + { + // ignore + } + string prev = _progress["log"]?.ToString() ?? ""; + string combined = (prev + line + "\n"); + if (combined.Length > 12000) + { + combined = combined[^12000..]; + } + _progress["log"] = combined; + Match lossM = LossRe.Match(line); + if (lossM.Success) + { + _progress["loss"] = lossM.Groups[1].Value; + } + Match stepM = StepRe.Match(line); + if (stepM.Success) + { + _progress["step"] = int.Parse(stepM.Groups[1].Value); + _progress["total_steps"] = int.Parse(stepM.Groups[2].Value); + int total = int.Parse(stepM.Groups[2].Value); + int step = int.Parse(stepM.Groups[1].Value); + _progress["percent"] = total > 0 ? (int)(100.0 * step / total) : 0; + } + try + { + _ext?.Memory?.SaveTrainJob(new JObject + { + ["id"] = _jobId, + ["status"] = "running", + ["progress"] = _progress, + }); + } + catch + { + // ignore + } + } + } + + async Task OnExited() + { + bool ok = false; + string adapterDir = ""; + string outputName = ""; + lock (_lock) + { + ok = _process?.ExitCode == 0; + IsRunning = false; + _progress["status"] = ok ? "completed" : "failed"; + _progress["exit_code"] = _process?.ExitCode; + } + if (_ext != null) + { + JObject job = _ext.Memory.GetTrainJob(_jobId); + try + { + JObject cfg = JObject.Parse(job?["config_json"]?.ToString() ?? "{}"); + adapterDir = cfg["adapter_dir"]?.ToString() ?? ""; + outputName = cfg["output_name"]?.ToString() ?? job?["output_name"]?.ToString() ?? ""; + } + catch + { + // ignore + } + JObject runner = _ext.Config.LoadTrainingRunner(); + await _ext.FinishTrainJobAsync(_jobId, ok, _logPath, _session, _baseUrl, _chatModel, adapterDir, outputName, runner["gguf_script"]?.ToString()); + } + } + + public void Cancel() + { + lock (_lock) + { + try + { + if (_process != null && !_process.HasExited) + { + _process.Kill(entireProcessTree: true); + } + } + catch + { + // ignore + } + IsRunning = false; + _progress["status"] = "cancelled"; + } + } + + public JObject GetProgress() + { + lock (_lock) + { + return (JObject)_progress.DeepClone(); + } + } + + public void ClearRunning() + { + lock (_lock) + { + IsRunning = false; + } + } +} diff --git a/README.md b/README.md index 1d6a14f..9a1e26a 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + **Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both. +**Version 0.12.1** — **Услышанное → агент**: одобренные примеры датасета сразу попадают в vector memory (`kind=heard`) и в контекст чата как `heard_examples` (без QLoRA). На вкладке «Датасет»: авто-подключение при одобрении, синхронизация всех, per-sample 🔗. Агент может запросить `heard_search`. Настройки: `training-agent.json`. + +**Version 0.12.0** — App-level tabs (Чат / Карточки / **Обучение** / Настройки), боковая панель истории чатов, вкладка обучения LLM: курирование диалогов, импорт JSONL/CSV, Hugging Face datasets (фильтр совместимости), быстрый Ollama Modelfile, опциональный QLoRA-раннер с локаутом VRAM. HF token из SwarmUI User Settings (`huggingface_api`). + **Version 0.11.9** — Distilled client: esbuild bundle (`Assets/assistent.bundle.js`), unified patch keys (`Config/_base/patch-keys.json`), taste stack removed (UserPrefs only), chat storage merge + all-chats disk save, `write_prompt` → alias of `ordinary`. Builds on prior 0.11.9 turn-intent work. **Version 0.11.9** — One turn, one decision. Nested hops (Krea prep, empty-patch retry, vision, critique) share a `turnHops` budget and pass the busy gate — Krea prep and the empty-patch retry were silently no-ops since 0.10.22/0.11.2. Generate / `look_at` are decided in a single `resolveTurnIntent`; `ensureGenerateAction`, `shouldHonorLookAt` and the `wantsGen`/`willGen`/`suppressGen` tangle are gone. Builds on 0.11.8. @@ -204,6 +208,14 @@ Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client v | `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) | | `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` | | `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM | +| `AssistentListTrainSamples` / `AssistentUpsertTrainSample` / `AssistentDeleteTrainSample` | Training samples in sqlite | +| `AssistentBuildDatasetFromChats` / `AssistentImportDataset` / `AssistentExportDataset` | Dataset from chats / file import / JSONL export | +| `AssistentCreateOllamaModel` | Build Ollama model from Modelfile (SYSTEM + few-shot) | +| `AssistentSearchHfDatasets` / `AssistentCheckHfDataset` / `AssistentPreviewHfDataset` / `AssistentImportHfDataset` | Hugging Face datasets (gated by schema) | +| `AssistentStartTrainJob` / `AssistentCancelTrainJob` / `AssistentGetTrainJob` / `AssistentTrainWS` | QLoRA runner + progress | +| `AssistentGetRunnerSettings` / `AssistentSaveRunnerSettings` | Python runner config overlay | +| `AssistentGetDatasetAgentSettings` / `AssistentSaveDatasetAgentSettings` | «Услышанное» → agent RAG (`training-agent.json`) | +| `AssistentLinkTrainSampleToAgent` / `AssistentUnlinkTrainSampleFromAgent` / `AssistentSyncDatasetToAgent` | Embed approved samples as `heard` memory | ## License diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index ccc8718..e7154ab 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -33,8 +33,8 @@ public partial class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.11.9"; - Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"]; + Version = "0.12.1"; + Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard"]; } public override void OnInit() @@ -82,7 +82,29 @@ public partial class SwarmAssistentExtension : Extension API.RegisterAPICall(AssistentForgetUserPref, true, PermUse); API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse); API.RegisterAPICall(AssistentClearMemory, true, PermUse); - Logs.Init("Swarm Assistent extension loaded (settings panel + user prefs + craft memory)"); + API.RegisterAPICall(AssistentListTrainSamples, false, PermUse); + API.RegisterAPICall(AssistentUpsertTrainSample, true, PermUse); + API.RegisterAPICall(AssistentDeleteTrainSample, true, PermUse); + API.RegisterAPICall(AssistentBuildDatasetFromChats, false, PermUse); + API.RegisterAPICall(AssistentImportDataset, true, PermUse); + API.RegisterAPICall(AssistentExportDataset, false, PermUse); + API.RegisterAPICall(AssistentCreateOllamaModel, true, PermUse); + API.RegisterAPICall(AssistentSearchHfDatasets, false, PermUse); + API.RegisterAPICall(AssistentCheckHfDataset, false, PermUse); + API.RegisterAPICall(AssistentPreviewHfDataset, false, PermUse); + API.RegisterAPICall(AssistentImportHfDataset, true, PermUse); + API.RegisterAPICall(AssistentStartTrainJob, true, PermUse); + API.RegisterAPICall(AssistentCancelTrainJob, true, PermUse); + API.RegisterAPICall(AssistentGetTrainJob, false, PermUse); + API.RegisterAPICall(AssistentTrainWS, true, PermUse); + API.RegisterAPICall(AssistentSaveRunnerSettings, true, PermUse); + API.RegisterAPICall(AssistentGetRunnerSettings, false, PermUse); + API.RegisterAPICall(AssistentGetDatasetAgentSettings, false, PermUse); + API.RegisterAPICall(AssistentSaveDatasetAgentSettings, true, PermUse); + API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse); + API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse); + API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse); + Logs.Init("Swarm Assistent extension loaded (0.12.1 heard dataset → agent)"); } int CfgInt(string key, int fallback) diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index 30d06b2..221fe25 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -2,161 +2,290 @@ -
- - -
-
-
- Assistent - - -
- - - Новый чат -
-
- - - -
-
- -
-
- - -
- - - обычный - - -
-
-
-
-
-
Совместная работа с Krea 2
-
Напиши промпт, кинь refs, выбери персону или открой Карточки для LoRA.
-
-
- -
- - -
- - -
-
- - - -
- - - +
-
+
diff --git a/docs/reviews/2026-08-22-review-1.md b/docs/reviews/2026-08-22-review-1.md new file mode 100644 index 0000000..5033e77 --- /dev/null +++ b/docs/reviews/2026-08-22-review-1.md @@ -0,0 +1,127 @@ +# Project Review — 2026-08-22 (1) + +Scope: Swarm Assistent 0.11.8 (client `Assets/assistent.js`, C# pipeline, packs, HTML/CSS). Includes a verification of the 0.11.8 ship (`a5e96f7`). + +## Prior Reviews Summary + +> Based on the last 3 review files analysed in Phase 0. + +### Still Open (carried forward) +None. + +### Resolved Since Last Review +None. (no prior `docs/reviews/` files) + +--- + +## 0.11.8 verification + +Checked against the ship notes: session_exact, slim `/debug ask`, generate-only-for-frames. + +**Correct** +- `ListPacks` skips `hidden: true`; `LoadPackPrompt` still loads enabled hidden packs (`debug_explain.json` has `hidden: true`, `enabled: true`). +- Slim debug skips prefs, skills, identity, RAG retrieve, tool hops, and memory/pref/persona writes; Exact still loads when `includeBase` is false. +- Client `fromDebug` sends pack `debug_explain`, `includeBase: false`, `skipAppendUser`, empty skills; dump stays a system note. +- Single `doParams` body; `shouldRememberSessionParam` remembers when the user asked **or** the value differs from Exact. +- `userImpliesGenerate` has no noun-only fallback; `userIsChatNotFrame` strips generate on thanks/trivia; `willGen` no longer ORs the auto-generate checkbox. +- Auto-critique / auto-vision HTML defaults remain unchecked (0.11.4 opt-in). +- Negative pass-through and variant-interrupt epoch guard still in place. + +**Not fully correct (filed below)** +- Nested Krea-prep / empty-patch retry `sendChat` is a no-op while `state.busy` is true (vision/critique hops are exempt; these are not). +- `fromDebug` returns after `rememberLastPatch` / `interrupt` / `maybeVisionHop`. +- Model `actions:["generate"]` still runs Generate unless `userIsChatNotFrame` matches. +- `shouldHonorLookAt` still honors unsolicited `look_at` on non-generate turns. + +--- + +## Phase 1: Code Quality + +### SOLID +`Assets/assistent.js` is a single IIFE (~9600 lines) owning chat, board, patches, inventory, personas, cards, and settings. C# is split into `partial` files on `SwarmAssistentExtension`, which is appropriate for a SwarmUI extension. No extra SOLID tasks beyond the concrete bugs below. + +### Performance +- Every RAG retrieve loads the full `memories` table plus embeddings, then filters in C# — `AssistentMemory.cs` line 750. + +### Correctness & Bugs +- Nested `sendChat` from `handleReplySideEffects` (Krea EN prep, empty-patch retry) hits the `state.busy` gate and returns without sending — `Assets/assistent.js` lines 8622–8624, 8104–8115, 8169–8181. +- `/debug` side effects run before the Q&A early return — lines 8120–8160. +- C# `TryParsePatch` returns the **first** fence; JS `extractPatch` keeps the **last** — `AssistentPatch.cs` line 97 vs `Assets/assistent.patch.js` line 88. +- `ResolveEnabledSkills` ignores `skills: []` (`Count > 0`) so the user cannot disable all skills — `AssistentConfig.cs` line 1474. Slim debug is unaffected because it skips the skills layer. +- `doInterruptNow` bumps `chatEpoch` without `clearInFlightUi`; `finishOk` then bails and leaves `state.busy` true — `Assets/assistent.js` lines 4629–4635, 8867–8868. +- Failed `RunToolHop` (`follow == null`) `break`s the hop loop, dropping sibling tools (e.g. empty `memory_get` kills `search_civitai`) — `AssistentChatPipeline.cs` lines 237–239. +- Overlay JSON writes never take `AssistentConfig._lock` (the field is unused) — `AssistentConfig.cs` line 18. +- Wanted YAML is load–mutate–`WriteAllText` with no lock — `AssistentWanted.cs` lines 46–77. +- Patch key lists diverge: JS has `persona_clone` / `persona`, neither list has `scheduler` (but `applyPatch` writes scheduler) — `AssistentPatch.cs` lines 12–25, `Assets/assistent.patch.js` lines 8–19, `Assets/assistent.js` line 4327. + +### Code Quality +No extra tasks. Duplicated help strings (JS fallback vs `ui.json`) are filed under UX. + +--- + +## Phase 2: Logical Consistency + +### Domain & Application Layer +No layered DDD. Pack/core contracts vs client behavior are the real domain rules. + +### Data Flow +- Server hops parse the first JSON fence; the UI applies the last. Hops/`ApplyMemoryActions` can follow a weak fence while Generate uses a later one. +- `includeBase` only gates `core.md`; Exact/live still flow on debug turns (intended). + +### State Management +- `fromDebug` can overwrite `state.lastPatch` and start a vision hop before the early return. +- `skipAppendUser` persists an assistant explanation with no matching user turn in `state.history` — next chat turns see a dangling assistant message. + +### Consistency +- `write_prompt.md` always demands a JSON patch with `prompt`+`negative`; `core.md` output contract shows a generate example as mandatory, while later saying “Pure Q&A: omit the JSON patch”. Client auto-apply will still write Swarm fields if the model emits a prompt patch on chat. +- `shouldHonorLookAt` returns true whenever there is `look_at` and no generate trigger — contradicts 0.11.4 / pack “look only if asked”. +- `wantsGen` trusts model `actions:["generate"]` unless `userIsChatNotFrame` (narrow). `synthesizePatchAfterEmptyFence` can invent `actions:["generate"]` when the reply has an empty `### JSON Patch` heading even if the user did not ask for a frame. + +--- + +## Phase 3: UI/UX + +### Usability +- Loading spinner, Stop, empty chat/board states exist. +- Chat clear confirms; **Clear Init+Mask** in the board ⋯ menu does not. +- `/debug ask` dump as a system note (no second user bubble) is correct. + +### Visual & Consistency +No token/theme issues filed. Status and health use text plus color. + +### Interaction & Feedback +- Interrupt from the Stop button clears UI; interrupt from a model patch does not (see busy-stuck bug). +- README slash table and `/pack` error string lag behind `Config/_base/ui.json`. + +### Accessibility +- Composer `#sa_input` has only a placeholder (no accessible name). +- Persona/pack/model ``; + root.appendChild(div); + } + } + + async function upsertSample(patch) { + await SA.request('AssistentUpsertTrainSample', patch); + await refreshSamples(); + } + + function renderHfList() { + const root = $('sa_hf_list'); + if (!root) return; + root.innerHTML = ''; + const showAll = !!$('sa_hf_show_all')?.checked; + for (const r of state.hfResults) { + if (!showAll && r.gate === 'rejected') continue; + const row = document.createElement('div'); + row.className = 'sa-hf-row' + (state.hfSelected === r.id ? ' sa-hf-row-active' : '') + (r.gate === 'rejected' ? ' sa-hf-rejected' : ''); + row.dataset.id = r.id; + const badge = r.gate === 'ok' ? 'ok' : r.gate === 'mapping' ? 'map' : 'no'; + row.innerHTML = `${escapeHtml(r.gate)}${escapeHtml(r.id)}${escapeHtml(r.reason || '')}`; + root.appendChild(row); + } + } + + async function searchHf() { + const q = ($('sa_hf_search')?.value || '').trim(); + setTrainStatus('Поиск…'); + try { + const data = await SA.request('AssistentSearchHfDatasets', { + q, + limit: 24, + show_all: !!$('sa_hf_show_all')?.checked, + }); + state.hfResults = data?.results || []; + renderHfList(); + setTrainStatus(`Найдено: ${state.hfResults.length}`); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + + async function checkHfLink() { + const link = ($('sa_hf_link')?.value || '').trim(); + const status = $('sa_hf_status'); + if (!link) return; + if (status) status.textContent = 'Проверяю…'; + try { + const data = await SA.request('AssistentCheckHfDataset', { dataset: link }); + state.hfCheck = data; + state.hfSelected = data.id; + if (status) { + status.textContent = data.gate === 'rejected' + ? `Отклонено: ${data.reason}` + : `${data.gate}: ${data.reason || 'OK'}`; + } + const preview = $('sa_hf_preview'); + if (preview) { + preview.hidden = false; + preview.textContent = JSON.stringify(data.sample_rows || data.features || data, null, 2).slice(0, 8000); + } + const importRow = $('sa_hf_import_row'); + if (importRow) importRow.hidden = data.gate === 'rejected'; + } catch (e) { + if (status) status.textContent = String(e.message || e); + } + } + + async function importHf() { + if (!state.hfSelected && !state.hfCheck?.id) { + setTrainStatus('Сначала проверь набор'); + return; + } + const id = state.hfSelected || state.hfCheck.id; + const limit = Number($('sa_hf_import_limit')?.value) || 200; + try { + const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit }); + setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`); + await refreshSamples(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + + async function syncModelfileModels() { + try { + const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || ''; + const data = await SA.request('AssistentListModels', { baseUrl }); + const models = data?.models || []; + for (const selId of ['sa_modelfile_base']) { + const sel = $(selId); + if (!sel) continue; + const cur = sel.value; + sel.innerHTML = ''; + for (const m of models) { + const opt = document.createElement('option'); + opt.value = m; + opt.textContent = m; + sel.appendChild(opt); + } + if (cur) sel.value = cur; + } + const personaSel = $('sa_modelfile_persona'); + if (personaSel && $('sa_persona')) { + personaSel.innerHTML = $('sa_persona').innerHTML; + personaSel.value = $('sa_persona').value || 'neutral'; + } + } catch (e) { /* ignore */ } + } + + async function createModelfile() { + setTrainStatus('Создаю модель…'); + try { + const data = await SA.request('AssistentCreateOllamaModel', { + base_url: $('sa_base_url')?.value, + base_model: $('sa_modelfile_base')?.value, + name: $('sa_modelfile_name')?.value, + persona: $('sa_modelfile_persona')?.value, + system: $('sa_modelfile_system')?.value, + shots: Number($('sa_modelfile_shots')?.value) || 8, + num_ctx: Number($('sa_modelfile_num_ctx')?.value) || 16384, + temperature: Number($('sa_modelfile_temp')?.value) || 0.7, + }); + setTrainStatus(`Готово: ${data.name}`); + SA.app?.refreshModels?.(); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + + function setTrainMode(mode) { + $('sa_train_form_modelfile').hidden = mode !== 'modelfile'; + $('sa_train_form_qlora').hidden = mode !== 'qlora'; + } + + function setTrainingLock(on, text) { + const root = $('swarm_assistent_root'); + const banner = $('sa_train_banner'); + if (root) root.classList.toggle('sa-root-training-lock', !!on); + if (banner) { + banner.hidden = !on; + const t = $('sa_train_banner_text'); + if (t && text) t.textContent = text; + } + SA.app?.setTrainingLock?.(!!on); + } + + async function pollTrainJob() { + try { + const data = await SA.request('AssistentGetTrainJob', {}); + const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null; + const active = data?.training_active || data?.job?.status === 'running'; + setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…'); + const logEl = $('sa_train_log'); + const bar = $('sa_train_progress_fill'); + const box = $('sa_train_progress'); + if (prog) { + if (box) box.hidden = false; + if (bar && prog.percent != null) bar.style.width = `${prog.percent}%`; + if (logEl && prog.log) logEl.textContent = prog.log; + } + if (!active) { + clearInterval(state.polling); + state.polling = null; + $('sa_btn_qlora_cancel').hidden = true; + } + } catch (e) { /* ignore */ } + } + + async function startQlora() { + setTrainStatus('Запуск…'); + try { + await SA.request('AssistentStartTrainJob', { + base_url: $('sa_base_url')?.value, + chat_model: $('sa_model')?.value, + base_model: $('sa_qlora_base')?.value, + output_name: $('sa_qlora_name')?.value, + rank: Number($('sa_qlora_rank')?.value) || 16, + alpha: Number($('sa_qlora_alpha')?.value) || 32, + lr: Number($('sa_qlora_lr')?.value) || 0.0002, + epochs: Number($('sa_qlora_epochs')?.value) || 3, + seq_len: Number($('sa_qlora_seq')?.value) || 2048, + four_bit: !!$('sa_qlora_4bit')?.checked, + hf_dataset: ($('sa_qlora_hf_dataset')?.value || '').trim() || undefined, + }); + $('sa_btn_qlora_cancel').hidden = false; + setTrainingLock(true, 'Идёт тренировка…'); + if (state.polling) clearInterval(state.polling); + state.polling = setInterval(pollTrainJob, 1500); + pollTrainJob(); + setTrainStatus('Тренировка запущена'); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + + async function cancelQlora() { + try { + await SA.request('AssistentCancelTrainJob', {}); + setTrainingLock(false); + setTrainStatus('Отменено'); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + + async function refreshTrainModels() { + const root = $('sa_train_models_list'); + if (!root) return; + try { + const data = await SA.request('AssistentListModels', { baseUrl: $('sa_base_url')?.value }); + const models = data?.models || []; + root.innerHTML = models.length + ? models.map((m) => `
${escapeHtml(m)}
`).join('') + : '
Нет моделей
'; + } catch (e) { + root.innerHTML = `
${escapeHtml(e.message)}
`; + } + } + + async function saveRunner() { + try { + await SA.request('AssistentSaveRunnerSettings', { + python: $('sa_runner_python')?.value, + kind: $('sa_runner_kind')?.value, + workdir: $('sa_runner_workdir')?.value, + cmd: $('sa_runner_cmd')?.value, + gguf_script: $('sa_runner_gguf_script')?.value, + }); + setTrainStatus('Раннер сохранён'); + } catch (e) { + setTrainStatus(String(e.message || e)); + } + } + + async function loadRunner() { + try { + const data = await SA.request('AssistentGetRunnerSettings', {}); + const s = data?.settings || {}; + if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python; + if ($('sa_runner_kind') && s.kind) $('sa_runner_kind').value = s.kind; + if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir; + if ($('sa_runner_cmd') && s.cmd) $('sa_runner_cmd').value = s.cmd; + if ($('sa_runner_gguf_script') && s.gguf_script) $('sa_runner_gguf_script').value = s.gguf_script; + } catch (e) { /* ignore */ } + } + + function wireTraining() { + if (window.__saTrainingWired) return; + window.__saTrainingWired = true; + document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => { + btn.addEventListener('click', () => setTrainingTab(btn.getAttribute('data-ttab'))); + }); + $('sa_btn_agent_sync')?.addEventListener('click', syncAllToAgent); + $('sa_agent_heard_enabled')?.addEventListener('change', saveAgentHeardSettings); + $('sa_agent_auto_link')?.addEventListener('change', saveAgentHeardSettings); + $('sa_agent_heard_quota')?.addEventListener('change', saveAgentHeardSettings); + loadAgentHeardSettings(); + $('sa_btn_train_from_chats')?.addEventListener('click', async () => { + try { + const data = await SA.request('AssistentBuildDatasetFromChats', {}); + setTrainStatus(`Из чатов: +${data.added}`); + await refreshSamples(); + } catch (e) { setTrainStatus(String(e.message || e)); } + }); + $('sa_btn_train_import_file')?.addEventListener('click', () => $('sa_train_import_file')?.click()); + $('sa_train_import_file')?.addEventListener('change', async (e) => { + const file = e.target?.files?.[0]; + if (!file) return; + const text = await file.text(); + try { + const data = await SA.request('AssistentImportDataset', { format: 'auto', content: text }); + setTrainStatus(`Импорт: ${data.imported}`); + await refreshSamples(); + } catch (err) { setTrainStatus(String(err.message || err)); } + e.target.value = ''; + }); + $('sa_btn_train_export')?.addEventListener('click', async () => { + try { + const data = await SA.request('AssistentExportDataset', { status: 'approved' }); + if (data.content) { + const blob = new Blob([data.content], { type: 'application/jsonl' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = 'assistent-dataset.jsonl'; + a.click(); + } + setTrainStatus(`Экспорт: ${data.count} примеров`); + } catch (e) { setTrainStatus(String(e.message || e)); } + }); + $('sa_train_filter_status')?.addEventListener('change', refreshSamples); + $('sa_train_filter_persona')?.addEventListener('change', refreshSamples); + $('sa_train_samples')?.addEventListener('click', async (e) => { + const row = e.target.closest('.sa-train-sample'); + if (!row) return; + const id = row.dataset.id; + const sample = state.samples.find((s) => s.id === id); + if (!sample) return; + if (e.target.closest('[data-approve]')) { + await upsertSample({ ...sample, status: 'approved' }); + await loadAgentHeardSettings(); + } else if (e.target.closest('[data-reject]')) { + await upsertSample({ ...sample, status: 'rejected' }); + await loadAgentHeardSettings(); + } else if (e.target.closest('[data-link]')) { + try { + const data = await SA.request('AssistentLinkTrainSampleToAgent', { id }); + state.agentLinked = data?.linked ?? state.agentLinked; + setAgentHeardStats(state.agentLinked); + setTrainStatus('Пример подключён к агенту'); + await refreshSamples(); + } catch (err) { setTrainStatus(String(err.message || err)); } + } else if (e.target.closest('[data-unlink]')) { + try { + const data = await SA.request('AssistentUnlinkTrainSampleFromAgent', { id }); + state.agentLinked = data?.linked ?? state.agentLinked; + setAgentHeardStats(state.agentLinked); + setTrainStatus('Пример отключён от агента'); + await refreshSamples(); + } catch (err) { setTrainStatus(String(err.message || err)); } + } else if (e.target.closest('[data-del]')) { + if (window.confirm('Удалить пример?')) { + await SA.request('AssistentDeleteTrainSample', { id }); + await refreshSamples(); + } + } + }); + $('sa_btn_hf_search')?.addEventListener('click', searchHf); + $('sa_hf_show_all')?.addEventListener('change', () => { renderHfList(); }); + $('sa_hf_list')?.addEventListener('click', async (e) => { + const row = e.target.closest('.sa-hf-row'); + if (!row || row.classList.contains('sa-hf-rejected')) return; + state.hfSelected = row.dataset.id; + $('sa_hf_link').value = row.dataset.id; + renderHfList(); + await checkHfLink(); + }); + $('sa_btn_hf_check')?.addEventListener('click', checkHfLink); + $('sa_btn_hf_import')?.addEventListener('click', importHf); + document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => { + r.addEventListener('change', () => setTrainMode(r.value)); + }); + $('sa_btn_modelfile_create')?.addEventListener('click', createModelfile); + $('sa_btn_qlora_start')?.addEventListener('click', startQlora); + $('sa_btn_qlora_cancel')?.addEventListener('click', cancelQlora); + $('sa_btn_train_models_refresh')?.addEventListener('click', refreshTrainModels); + $('sa_btn_save_runner')?.addEventListener('click', saveRunner); + loadRunner(); + setTrainMode('modelfile'); + } + + SA.training = { + render() { + wireTraining(); + setTrainingTab(state.ttab); + }, + async curateFromChat(messages, meta) { + try { + await SA.request('AssistentUpsertTrainSample', { + source: 'chat', + chat_id: meta?.chatId, + persona: meta?.persona, + pack: meta?.pack, + status: meta?.status || 'approved', + messages, + }); + return true; + } catch (e) { + console.warn('curateFromChat', e); + return false; + } + }, + setTrainingLock, + pollTrainJob, + }; +}