From 5f33130381b35a2a527e293e35e141bcc1aec23f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 23 Aug 2026 06:50:08 +0300 Subject: [PATCH] Ship Assistent 0.14.1: Exact generate params and cheap park/warm. Client always merges Exact turbo|raw steps/cfg/sigma before Generate so sparse LLM omissions and leftover SD 20/7 cannot stick; Ollama park/warm skip no-op /api/ps round-trips when the chat model is already unloaded or resident. Co-authored-by: Cursor --- Assets/assistent.bundle.js | 359 ++++++++++++++++++++++++++-- AssistentVram.cs | 76 +++++- Config/_base/core/core.md | 5 +- Config/_base/exact.json | 4 +- Config/_base/packs/compose_scene.md | 3 +- Config/_base/packs/fix_params.md | 2 +- Config/_base/packs/ordinary.md | 2 +- README.md | 11 +- SwarmAssistentExtension.cs | 4 +- Tabs/Text2Image/Assistent.html | 2 +- package.json | 2 +- src/app.js | 265 ++++++++++++++++++-- src/kreaProfile.js | 108 +++++++++ src/main.js | 2 + src/session.js | 61 +++++ test/kreaProfile.test.js | 84 +++++++ test/patch.test.js | 65 +++++ 17 files changed, 1005 insertions(+), 50 deletions(-) create mode 100644 src/kreaProfile.js create mode 100644 test/kreaProfile.test.js diff --git a/Assets/assistent.bundle.js b/Assets/assistent.bundle.js index 9276c95..c73830f 100644 --- a/Assets/assistent.bundle.js +++ b/Assets/assistent.bundle.js @@ -640,6 +640,48 @@ const ask = patchAskList(delta); return { generate, look, vetoed, ask }; } + var EXACT_GENERATE_PARAM_KEYS = ["steps", "cfg", "sigma_shift"]; + function resolveExactProfileDefaults({ exact, profiles, profileName } = {}) { + const gen = exact?.generation && typeof exact.generation === "object" ? exact.generation : {}; + const profile = profileName || gen.profile || "turbo"; + const fromProfile = profiles?.[profile] && typeof profiles[profile] === "object" ? profiles[profile] : {}; + return { + profile, + steps: fromProfile.steps ?? gen.steps ?? null, + cfg: fromProfile.cfg ?? gen.cfg ?? null, + sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null + }; + } + function mergeExactParamsForGenerate(patch, { + exact, + profiles, + profileName, + sessionExact, + userParamIntent + } = {}) { + if (!patchWantsGenerate(patch)) { + return { patch, clearSessionKeys: [], profile: null }; + } + const defaults = resolveExactProfileDefaults({ exact, profiles, profileName }); + const out = { ...patch }; + const clearSessionKeys = []; + for (const key of EXACT_GENERATE_PARAM_KEYS) { + if (out[key] != null) { + continue; + } + if (userParamIntent && sessionExact?.[key] != null) { + out[key] = sessionExact[key]; + continue; + } + if (defaults[key] != null) { + out[key] = defaults[key]; + if (sessionExact?.[key] != null && String(sessionExact[key]) !== String(defaults[key])) { + clearSessionKeys.push(key); + } + } + } + return { patch: out, clearSessionKeys, profile: defaults.profile }; + } function attachSession(SA2) { SA2.session = { emptySession, @@ -653,6 +695,9 @@ compactContext, fullSettingsDump, resolveTurnIntent, + resolveExactProfileDefaults, + mergeExactParamsForGenerate, + EXACT_GENERATE_PARAM_KEYS, GEN_KEYS }; } @@ -1038,6 +1083,86 @@ SA2.createActivityController = createActivityController; } + // src/kreaProfile.js + function numClose(a, b, eps = 0.051) { + const x = Number(a); + const y = Number(b); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return false; + } + return Math.abs(x - y) <= eps; + } + function detectKreaProfileName(modelBlob, exactProfile) { + const blob = String(modelBlob || "").toLowerCase(); + const hasTurbo = /turbo/.test(blob); + const hasRaw = /\braw\b|_raw\b|-raw\b|\/raw\b/.test(blob); + if (hasTurbo) { + return "turbo"; + } + if (hasRaw) { + return "raw"; + } + void exactProfile; + return "raw"; + } + function profileParamDefaults(profiles, profileName, generation = {}) { + const profile = String(profileName || "raw"); + const fromProfile = profiles && typeof profiles[profile] === "object" ? profiles[profile] : {}; + const gen = generation && typeof generation === "object" ? generation : {}; + return { + profile, + steps: fromProfile.steps ?? gen.steps ?? null, + cfg: fromProfile.cfg ?? gen.cfg ?? null, + sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null + }; + } + function liveMatchesExactProfile(live, defaults) { + if (!defaults || defaults.steps == null || defaults.cfg == null) { + return true; + } + const stepsOk = live?.steps == null || numClose(live.steps, defaults.steps, 0.5); + const cfgOk = live?.cfg == null || numClose(live.cfg, defaults.cfg); + const sigmaOk = defaults.sigma_shift == null || live?.sigma_shift == null || numClose(live.sigma_shift, defaults.sigma_shift); + return stepsOk && cfgOk && sigmaOk; + } + function exactKeysToForce(live, defaults, { + patch = null, + sessionExact = null, + userParamIntent = false + } = {}) { + if (!defaults) { + return []; + } + const out = []; + for (const key of ["steps", "cfg", "sigma_shift"]) { + const want = defaults[key]; + if (want == null) { + continue; + } + const eps = key === "steps" ? 0.5 : 0.051; + if (patch && patch[key] != null && !numClose(patch[key], want, eps)) { + continue; + } + if (userParamIntent && sessionExact && sessionExact[key] != null) { + continue; + } + const have = live?.[key]; + if (have == null || !numClose(have, want, eps)) { + out.push(key); + } + } + return out; + } + function attachKreaProfile(SA2) { + SA2.kreaProfile = { + numClose, + detectKreaProfileName, + profileParamDefaults, + liveMatchesExactProfile, + exactKeysToForce + }; + } + // src/app.js (function() { const LS_BASE = "swarm_assistent_base_url"; @@ -1559,6 +1684,7 @@ return false; } } + let lastExactForceCkpt = null; function updateGate() { const ok = isKreaSelected(); const gate = $2("sa_gate"); @@ -1579,6 +1705,19 @@ if (layout) { layout.classList.toggle("sa-disabled", !ok); } + try { + const ckptName = resolveCurrentCheckpoint()?.name || null; + if (ok && ckptName && ckptName !== lastExactForceCkpt) { + const defs = typeof exactProfileDefaults === "function" ? exactProfileDefaults(detectKreaProfileName2()) : null; + if (defs && defs.steps != null && defs.cfg != null) { + lastExactForceCkpt = ckptName; + forceExactParamsForGenerate({}); + } + } else if (!ok) { + lastExactForceCkpt = null; + } + } catch (e) { + } return ok; } function escapeHtml2(s) { @@ -1863,7 +2002,9 @@ if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) { return true; } - return cyrTokenRe("\u0440\u0430\u0437\u043C\u0435\u0440|\u0448\u0438\u0440\u0438\u043D[\u0430-\u044F\u0451]*|\u0432\u044B\u0441\u043E\u0442[\u0430-\u044F\u0451]*|\u0441\u043E\u043E\u0442\u043D\u043E\u0448\u0435\u043D[\u0430-\u044F\u0451]*|\u0442\u0443\u0440\u0431\u043E|\u043F\u043E\u0440\u0442\u0440\u0435\u0442|\u0432\u0435\u0440\u0442\u0438\u043A\u0430\u043B[\u0430-\u044F\u0451]*").test(t); + return cyrTokenRe( + "\u0440\u0430\u0437\u043C\u0435\u0440|\u0448\u0438\u0440\u0438\u043D[\u0430-\u044F\u0451]*|\u0432\u044B\u0441\u043E\u0442[\u0430-\u044F\u0451]*|\u0441\u043E\u043E\u0442\u043D\u043E\u0448\u0435\u043D[\u0430-\u044F\u0451]*|\u0442\u0443\u0440\u0431\u043E|\u043F\u043E\u0440\u0442\u0440\u0435\u0442|\u0432\u0435\u0440\u0442\u0438\u043A\u0430\u043B[\u0430-\u044F\u0451]*|\u0448\u0430\u0433|\u0448\u0430\u0433\u043E\u043C|\u0448\u0430\u0433\u0430\u043C\u0438|\u0448\u0430\u0433\u043E\u0432|\u0448\u0430\u0433\u0430" + ).test(t); } function replyMissingJsonPatch(reply) { const t = String(reply || ""); @@ -2127,7 +2268,7 @@ ${patch.prompt}`; const steps = val("input_steps") || "\u2014"; const cfg = val("input_cfgscale") || val("input_cfg") || "\u2014"; const seed = val("input_seed") || "\u2014"; - const profile = detectKreaProfileName(); + const profile = detectKreaProfileName2(); el.textContent = `${aspect} \xB7 ${w || "?"}\xD7${h || "?"} \xB7 steps ${steps} \xB7 cfg ${cfg} \xB7 ${profile} \xB7 seed ${seed}`; } function applyAspectTableFrom(obj) { @@ -2151,28 +2292,116 @@ ${patch.prompt}`; const profiles = exact.profiles || state.kreaProfiles || {}; return { exact, profiles }; } - function detectKreaProfileName() { + function detectKreaProfileName2() { + const KP = window.SA?.kreaProfile; try { const model = resolveCurrentCheckpoint(); - const blob = `${model?.name || ""} ${model?.title || ""}`.toLowerCase(); - const hasRaw = /\braw\b/.test(blob); - const hasTurbo = /\bturbo\b/.test(blob); - return hasRaw && !hasTurbo ? "raw" : "turbo"; + const blob = `${model?.name || ""} ${model?.title || ""}`; + const exactProfile = state.exact?.generation?.profile; + if (KP?.detectKreaProfileName) { + return KP.detectKreaProfileName(blob, exactProfile); + } + const lower = blob.toLowerCase(); + if (/turbo/.test(lower)) { + return "turbo"; + } + if (/\braw\b|_raw\b|-raw\b/.test(lower)) { + return "raw"; + } + return "raw"; } catch (e) { - return state.exact?.generation?.profile || "turbo"; + return state.exact?.generation?.profile || "raw"; } } + function exactProfileDefaults(profileName) { + const { exact, profiles } = resolveExactBundle(); + const profile = profileName || detectKreaProfileName2(); + const KP = window.SA?.kreaProfile; + if (KP?.profileParamDefaults) { + return KP.profileParamDefaults(profiles, profile, exact.generation); + } + const gen = exact.generation && typeof exact.generation === "object" ? exact.generation : {}; + const fromProfile = profiles[profile] && typeof profiles[profile] === "object" ? profiles[profile] : {}; + return { + profile, + steps: fromProfile.steps ?? gen.steps ?? null, + cfg: fromProfile.cfg ?? gen.cfg ?? null, + sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null + }; + } + function readLiveStepsCfgSigma() { + return { + steps: parseInt(val("input_steps") || "", 10) || null, + cfg: parseFloat(val("input_cfgscale") || val("input_cfg") || "") || null, + sigma_shift: parseFloat(val("input_sigmashift") || "") || null + }; + } + function setCfgVal(n) { + if (document.getElementById("input_cfgscale")) { + setVal("input_cfgscale", String(n)); + } else if (document.getElementById("input_cfg")) { + setVal("input_cfg", String(n)); + } + } + function forceExactParamsForGenerate({ patch = null, profileName = null } = {}) { + if (typeof isKreaSelected === "function" && !isKreaSelected()) { + return false; + } + const profile = profileName || detectKreaProfileName2(); + const defaults = exactProfileDefaults(profile); + const live = readLiveStepsCfgSigma(); + const KP = window.SA?.kreaProfile; + const userIntent = !!state.lastUserParamIntent; + const keys = KP?.exactKeysToForce ? KP.exactKeysToForce(live, defaults, { + patch, + sessionExact: state.sessionExact, + userParamIntent: userIntent + }) : ["steps", "cfg", "sigma_shift"].filter((k) => { + const want = defaults[k]; + if (want == null) { + return false; + } + if (patch?.[k] != null && String(patch[k]) !== String(want)) { + return false; + } + if (userIntent && state.sessionExact?.[k] != null) { + return false; + } + return live[k] == null || String(live[k]) !== String(want); + }); + if (!keys.length) { + return false; + } + for (const key of keys) { + const want = defaults[key]; + if (key === "steps") { + setVal("input_steps", String(want)); + } else if (key === "cfg") { + setCfgVal(want); + } else if (key === "sigma_shift") { + setVal("input_sigmashift", String(want)); + } + if (state.sessionExact && state.sessionExact[key] != null && String(state.sessionExact[key]) !== String(want)) { + delete state.sessionExact[key]; + } + if (state.chatSession?.gen) { + state.chatSession.gen[key] = want; + } + } + syncLiveParamsBar(); + return true; + } function mergedGenerationDefaults(profileName) { const { exact, profiles } = resolveExactBundle(); const gen = exact.generation && typeof exact.generation === "object" ? { ...exact.generation } : {}; - const profile = profileName || gen.profile || detectKreaProfileName(); + const profile = profileName || detectKreaProfileName2(); const fromProfile = profiles[profile] && typeof profiles[profile] === "object" ? { ...profiles[profile] } : {}; const session = state.sessionExact && typeof state.sessionExact === "object" ? { ...state.sessionExact } : {}; return { ...gen, ...fromProfile, profile, ...session }; } function exactDefaultFor(key, profileName) { const { exact, profiles } = resolveExactBundle(); - const profile = profileName || exact.generation?.profile || detectKreaProfileName(); + const profile = profileName || exact.generation?.profile || detectKreaProfileName2(); const fromProfile = profiles[profile]?.[key]; if (fromProfile != null) { return fromProfile; @@ -2206,6 +2435,36 @@ ${patch.prompt}`; } return String(value) !== String(exactVal); } + function ensureExactParamsForGenerate(patch) { + const S = window.SA && window.SA.session; + if (!patch || !S || typeof S.mergeExactParamsForGenerate !== "function") { + return patch; + } + if (!S.patchWantsGenerate(patch)) { + return patch; + } + const { exact, profiles } = resolveExactBundle(); + const { patch: next, clearSessionKeys } = S.mergeExactParamsForGenerate(patch, { + exact, + profiles, + profileName: detectKreaProfileName2(), + sessionExact: state.sessionExact, + userParamIntent: !!state.lastUserParamIntent + }); + for (const key of clearSessionKeys || []) { + if (state.sessionExact && state.sessionExact[key] != null) { + delete state.sessionExact[key]; + } + } + if (state.chatSession && state.chatSession.gen) { + for (const key of S.EXACT_GENERATE_PARAM_KEYS || ["steps", "cfg", "sigma_shift"]) { + if (next[key] != null) { + state.chatSession.gen[key] = next[key]; + } + } + } + return next; + } function fillEmptyParamsFromExact() { const defaults = mergedGenerationDefaults(); if (isEmptyParamField(val("input_steps"), { treatZeroEmpty: true }) && defaults.steps != null) { @@ -4554,6 +4813,8 @@ ${patch.prompt}`; if (typeof readInitContext === "function") initCtx = readInitContext(); } catch (e) { } + const kreaProfile = detectKreaProfileName2(); + const exactDefs = exactProfileDefaults(kreaProfile); const extra = { prompt_image_count: typeof countPromptImages === "function" ? countPromptImages() : 0, has_vision_image: typeof visionReadySlots === "function" ? visionReadySlots().length > 0 : false, @@ -4561,6 +4822,12 @@ ${patch.prompt}`; attached_slot_ids: typeof attachableSlots === "function" ? attachableSlots().map((s) => s.id) : [], auto_apply: !!$2("sa_auto_apply")?.checked, auto_generate: true, + krea_profile: kreaProfile, + recommended_params: { + steps: exactDefs.steps, + cfg: exactDefs.cfg, + sigma_shift: exactDefs.sigma_shift + }, ...initCtx }; if (S && typeof S.compactContext === "function") { @@ -4576,7 +4843,7 @@ ${patch.prompt}`; return ctx; } const g = state.chatSession && state.chatSession.gen || {}; - return { session: true, prompt: g.prompt || "", negative: g.negative || "", ...extra }; + return { session: true, prompt: g.prompt || "", negative: g.negative || "", krea_profile: kreaProfile, ...extra }; } function slimInventoryLoras(list, limit) { const selected = /* @__PURE__ */ new Set(); @@ -4858,6 +5125,11 @@ ${patch.prompt}`; if (!patch) { return; } + const S = window.SA && window.SA.session; + const wantsGen = S && typeof S.patchWantsGenerate === "function" && S.patchWantsGenerate(patch); + if (wantsGen) { + patch = ensureExactParamsForGenerate(patch); + } const doPrompt = !which || which === "all" || which === "prompt"; const doLoras = !which || which === "all" || which === "loras"; const doParams = !which || which === "all" || which === "size" || which === "params"; @@ -5038,6 +5310,10 @@ ${patch.prompt}`; setVal(batchId, String(defBatch)); } } + const Sgen = window.SA && window.SA.session; + if (Sgen?.patchWantsGenerate?.(patch) || patch.generate === true || Array.isArray(patch.actions) && patch.actions.map(String).includes("generate")) { + forceExactParamsForGenerate({ patch }); + } } if (doInit) { const creativity = patch.init_creativity != null ? patch.init_creativity : patch.denoise; @@ -5442,16 +5718,41 @@ ${patch.prompt}`; return true; } async function runGenerateFromPatch(patch, opts = {}) { + const deltaBeforeLive = patch && typeof patch === "object" ? { ...patch } : {}; + const S = window.SA && window.SA.session; + const exactKeys = S && S.EXACT_GENERATE_PARAM_KEYS || ["steps", "cfg", "sigma_shift"]; if (typeof pullLiveIntoSession === "function") pullLiveIntoSession(); - if (state.chatSession && state.chatSession.gen && (opts.fromSession || opts.force)) { - const fromSess = { ...state.chatSession.gen, generate: true, actions: ["generate"] }; - if (patch && typeof patch === "object") { - for (const k of Object.keys(patch)) { - if (patch[k] != null) fromSess[k] = patch[k]; + if (state.chatSession && state.chatSession.gen) { + for (const key of exactKeys) { + const fromDelta = deltaBeforeLive[key] != null; + const fromUser = !!state.lastUserParamIntent && state.sessionExact?.[key] != null; + if (!fromDelta && !fromUser) { + state.chatSession.gen[key] = null; } } - patch = fromSess; + } + let working = { + ...deltaBeforeLive, + generate: true, + actions: Array.isArray(deltaBeforeLive.actions) && deltaBeforeLive.actions.map(String).includes("generate") ? deltaBeforeLive.actions : [...Array.isArray(deltaBeforeLive.actions) ? deltaBeforeLive.actions : [], "generate"] + }; + working = ensureExactParamsForGenerate(working); + if (state.chatSession && state.chatSession.gen && (opts.fromSession || opts.force)) { + const fromSess = { ...state.chatSession.gen, generate: true, actions: ["generate"] }; + for (const k of Object.keys(working)) { + if (working[k] != null) fromSess[k] = working[k]; + } + for (const key of exactKeys) { + const fromDelta = deltaBeforeLive[key] != null; + const fromUser = !!state.lastUserParamIntent && state.sessionExact?.[key] != null; + if (!fromDelta && !fromUser && working[key] != null) { + fromSess[key] = working[key]; + } + } + patch = ensureExactParamsForGenerate(fromSess); if (typeof pushSessionToSwarm === "function") await pushSessionToSwarm(state.chatSession); + } else { + patch = working; } const force = !!opts.force; if (!force && false || !patchHasGenTrigger(patch)) { @@ -5506,6 +5807,10 @@ ${patch.prompt}`; syncLiveParamsBar(); } ensureNegativeForGenerate(job.patch); + forceExactParamsForGenerate({ patch: job.patch }); + if (typeof pullLiveIntoSession === "function") { + pullLiveIntoSession(); + } if (epoch !== state.chatEpoch) { break; } @@ -6406,7 +6711,12 @@ ${data.ui.help_extra}`.trim(); fillKnobsFromConfig(data); updateCtxChip(); if (applyDefaults || data.exact) { + lastExactForceCkpt = null; fillEmptyParamsFromExact(); + if (typeof isKreaSelected === "function" && isKreaSelected()) { + forceExactParamsForGenerate({}); + lastExactForceCkpt = resolveCurrentCheckpoint()?.name || null; + } } const nextPersona = data.persona || $2("sa_persona")?.value || ""; let controlValues = data.control_values || data.exact?.controls || {}; @@ -8035,6 +8345,13 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; status: intent.vetoed ? "skip" : "running" }); if (typeof startBusyUi === "function") startBusyUi("silent_gen"); + if (effective) { + effective = ensureExactParamsForGenerate({ + ...effective, + generate: true, + actions: Array.isArray(effective.actions) && effective.actions.map(String).includes("generate") ? effective.actions : [...Array.isArray(effective.actions) ? effective.actions : [], "generate"] + }); + } if (S && effective) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective); await pushSessionToSwarm(state.chatSession); if (typeof syncLiveParamsBar === "function") syncLiveParamsBar(); @@ -8058,13 +8375,16 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; state.pendingSilentGen = false; } async function applyQuickPatch(patch, note) { - const withActions = { ...patch }; + let withActions = { ...patch }; if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) { withActions.actions = ["generate"]; } const prevIntent = state.lastUserParamIntent; state.lastUserParamIntent = true; const S = window.SA && window.SA.session; + if (S && S.patchWantsGenerate && S.patchWantsGenerate(withActions)) { + withActions = ensureExactParamsForGenerate(withActions); + } if (S) { state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), withActions); } @@ -8126,7 +8446,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`; 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 profile = detectKreaProfileName2(); const defaults = mergedGenerationDefaults(profile); const session = state.sessionExact || {}; const exactGen = state.exact?.generation || state.config?.exact?.generation || {}; @@ -10095,6 +10415,7 @@ ${HELP_TEXT}`); attachSession(window.SA); attachContext(window.SA); attachActivity(window.SA); + attachKreaProfile(window.SA); window.SA.applyConfigPatchKeys = function(config) { const keys = config?.patch_keys; if (Array.isArray(keys) && keys.length) { diff --git a/AssistentVram.cs b/AssistentVram.cs index 2b28c8b..6b5f5a2 100644 --- a/AssistentVram.cs +++ b/AssistentVram.cs @@ -10,7 +10,9 @@ namespace Mrleo1nid.SwarmAssistent; /// VRAM handover between Ollama and the image backend: park the chat model before /// Generate, warm it again once the user is back in the chat. Embed / memory models are never -/// parked — they are tiny and reloading them stalls every retrieve. +/// parked — they are tiny and reloading them stalls every retrieve. +/// Warm is a no-op when Ollama already has the chat model resident (/api/ps) so post-Generate +/// reload is cheap when Krea did not evict VL. public partial class SwarmAssistentExtension { const string WarmKeepAlive = "15m"; @@ -28,6 +30,18 @@ public partial class SwarmAssistentExtension { return new JObject { ["success"] = true, ["parked"] = false, ["skipped"] = "memory model — never parked" }; } + // Already unloaded — skip the keep_alive:0 round-trip. + if (!await IsOllamaModelResident(root, name)) + { + return new JObject + { + ["success"] = true, + ["parked"] = false, + ["skipped"] = "not_resident", + ["model"] = name, + ["base_url"] = root, + }; + } JObject generate = new() { ["model"] = name, @@ -56,7 +70,8 @@ public partial class SwarmAssistentExtension return new JObject { ["success"] = true, ["parked"] = true, ["model"] = name, ["base_url"] = root }; } - /// Single-token chat so the model is resident again by the time the user types. + /// Single-token chat so the model is resident again by the time the user types. + /// Skips the load when /api/ps already lists the model (avoids ~20–30s no-op warm). public async Task AssistentWarmLlm(Session session, string baseUrl, string model) { string root = NormalizeBaseUrl(baseUrl); @@ -69,6 +84,17 @@ public partial class SwarmAssistentExtension { return new JObject { ["success"] = true, ["warmed"] = false, ["skipped"] = "memory model" }; } + if (await IsOllamaModelResident(root, name)) + { + return new JObject + { + ["success"] = true, + ["warmed"] = true, + ["skipped"] = "already_resident", + ["model"] = name, + ["keep_alive"] = WarmKeepAlive, + }; + } int numCtx = CfgInt("num_ctx", DefaultNumCtxFallback); JObject payload = new() { @@ -101,6 +127,50 @@ public partial class SwarmAssistentExtension }; } + /// True when Ollama /api/ps lists (or a matching tag). + async Task IsOllamaModelResident(string root, string model) + { + if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(model)) + { + return false; + } + try + { + using HttpResponseMessage resp = await HttpClient.GetAsync($"{root}/api/ps"); + if (!resp.IsSuccessStatusCode) + { + return false; + } + string body = await resp.Content.ReadAsStringAsync(); + JObject parsed = JObject.Parse(body); + JArray models = parsed["models"] as JArray ?? []; + foreach (JToken m in models) + { + string name = m["name"]?.ToString() ?? m["model"]?.ToString() ?? ""; + if (string.IsNullOrWhiteSpace(name)) + { + continue; + } + if (string.Equals(name, model, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + // Tags may differ by :latest vs bare name. + if (name.StartsWith(model + ":", StringComparison.OrdinalIgnoreCase) + || model.StartsWith(name + ":", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + catch (Exception ex) + { + Logs.Debug($"IsOllamaModelResident: {ex.Message}"); + return false; + } + } + static async Task<(bool ok, string body)> PostOllamaJson(string root, string route, JObject payload) { try @@ -108,7 +178,7 @@ public partial class SwarmAssistentExtension using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json"); using HttpResponseMessage resp = await HttpClient.PostAsync($"{root}{route}", content); string body = await resp.Content.ReadAsStringAsync(); - return (resp.IsSuccessStatusCode, resp.IsSuccessStatusCode ? body : $"HTTP {(int)resp.StatusCode}: {body}"); + return (resp.IsSuccessStatusCode, body); } catch (Exception ex) { diff --git a/Config/_base/core/core.md b/Config/_base/core/core.md index 5a3aeb1..85acce5 100644 --- a/Config/_base/core/core.md +++ b/Config/_base/core/core.md @@ -16,7 +16,9 @@ When instructions conflict, apply this order (highest wins): Exact = defaults encyclopedia. About the user = human taste. Session = what Generate will run. Soft craft notes may appear in context from the server — never override Exact, About the user, or the user’s param request. -**Sparse deltas only.** Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` / full `prompt` when they already match the session and the user did not ask to change them. +**Sparse deltas only.** Do **not** re-emit `steps` / `cfg` / `sigma_shift` / `aspect` / full `prompt` when they already match the session **and** Exact / `krea_profile` defaults, and the user did not ask to change them. + +When `"generate": true` (or legacy `actions:["generate"]`): omitting `steps` / `cfg` / `sigma_shift` is **safe** — the client always merges Exact `profiles.turbo` or `profiles.raw` for the live checkpoint before Generate. Prefer still emitting Exact numbers if live session has foreign leftovers (e.g. steps 20 / cfg 7 vs turbo 8 / 1) so the session stays honest. Never CFG 0. Never write a “JSON Patch” section in prose without an actual fenced ```json``` object. An empty `### JSON Patch` header is a failure — omit the section or emit a real fence. Chat prose may stay RU; **`prompt` must be English** (Krea 2). Keep prose short. **After the closing ``` of the JSON fence, STOP.** **One turn = one patch.** Several options → `variants` (2–4). Prompt structure lives in skill `prompting`. @@ -57,6 +59,7 @@ Several options (still one fence): ### Patch rules - Omit unchanged params. Include `negative` when starting Generate if live/session negative is empty or the scene needs a specific omit. +- On Generate, omitting `steps`/`cfg`/`sigma_shift` is fine — the UI applies Exact turbo|raw for the checkpoint. Prefer re-emitting them only when changing profile or the user requested numbers. - `loras` replaces the full intended set for this chat when you change LoRAs. - Prefer `aspect` over raw width/height. - Optional: seed, vary, init/mask, controls, pack, `variants`. diff --git a/Config/_base/exact.json b/Config/_base/exact.json index 6e8aea1..cb9b3d1 100644 --- a/Config/_base/exact.json +++ b/Config/_base/exact.json @@ -34,7 +34,7 @@ "prompt_language": "Chat model always prep's Generate prompt for Krea: English natural prose for Qwen3-VL, structured (subject→pose→setting→camera→light). Chat may be RU; never leave Russian or thin drafts in patch.prompt.", "negatives": "Qwen3-VL negatives are weak — still keep a short Swarm negative box. Prefer positives in `prompt`; for `negative`: create if live is empty (Exact generation.negative), lightly supplement if the scene needs a specific omit, or echo live unchanged. Never drop/clear the box on Generate. Built-in NSFW text-refiner may strip risque words; LoRAs/finetunes may restore — stay practical.", "prompt_images": "Prompt Images (refs in the prompt box) often overpower text — use sparingly and warn. Init Image = structure (img2img). Mask = local fix. They are not interchangeable. Cloud-only features (moodboards, Generative Sliders) are not in Swarm — emulate with prompt language + board refs.", - "turbo": "Krea 2 Turbo defaults live in exact.generation / exact.profiles.turbo. Never use CFG 0 — broken output. Side ~1024 (128–4096 OK).", - "raw": "Krea 2 RAW/Base: prefer exact.profiles.raw when checkpoint name/title looks like RAW (not Turbo). If a turbo LoRA exists, weight ~0.6 for photoreal (1.0 ≈ full turbo). Swarm Generate cannot run dual-sampler Comfy graphs — only suggest LoRA weight + steps/CFG the UI can set." + "turbo": "Krea 2 Turbo defaults live in exact.generation / exact.profiles.turbo. Never use CFG 0 — broken output. Side ~1024 (128–4096 OK). Checkpoint name must contain turbo to claim this profile.", + "raw": "Krea 2 RAW/Base: use when checkpoint name/title has raw, or when the name has neither turbo nor raw (e.g. realismByStableYogi finetunes). Prefer exact.profiles.raw. If a turbo LoRA exists, weight ~0.6 for photoreal (1.0 ≈ full turbo). Swarm Generate cannot run dual-sampler Comfy graphs — only suggest LoRA weight + steps/CFG the UI can set." } } diff --git a/Config/_base/packs/compose_scene.md b/Config/_base/packs/compose_scene.md index 10c2bcf..288c455 100644 --- a/Config/_base/packs/compose_scene.md +++ b/Config/_base/packs/compose_scene.md @@ -15,4 +15,5 @@ Goal: co-create a scene / moodboard direction for **Krea 2** (local Swarm). ## Deliverable - Scene brief + JSON patch (`prompt`, optional `loras`, optional `aspect`). -- `actions: ["generate"]` when ready to try the scene. +- `actions: ["generate"]` / `"generate": true` when ready to try the scene. +- Omitting `steps` / `cfg` / `sigma_shift` is safe — the client fills Exact turbo|raw for the live checkpoint. Prefer including them when live session disagrees with `krea_profile` (keeps the session honest). diff --git a/Config/_base/packs/fix_params.md b/Config/_base/packs/fix_params.md index e27f3ca..767d12a 100644 --- a/Config/_base/packs/fix_params.md +++ b/Config/_base/packs/fix_params.md @@ -5,7 +5,7 @@ Goal: adjust **generation parameters** for Krea 2 Turbo (or RAW if context says ## Guidelines - Prefer **Exact memory** (`profiles.turbo` / `profiles.raw`), live `recommended_params`, and `session_exact` over invented numbers. Never CFG 0. -- Prefer live context field `krea_profile` (`turbo` | `raw`) when present. +- Prefer live context field `krea_profile` (`turbo` | `raw`) when present. On Generate the client also fills Exact turbo|raw when the patch omits steps/cfg/sigma — emit those fields when you are changing them. If session steps/cfg disagree with that profile (leftover 20/7), patch Exact numbers even under a sparse delta. - **Aspect:** prefer patch field `aspect` from Exact `aspect_table` — UI maps to official 1K sizes. Else set width/height near 1024. - **Batch:** `images` or `batch` (1–4 typical). - **Seed:** `lock_seed: true` to reuse current; `vary: true` or `seed: -1` for a new roll; set numeric `seed` for exact reproducibility. diff --git a/Config/_base/packs/ordinary.md b/Config/_base/packs/ordinary.md index 6e64c28..3a4336e 100644 --- a/Config/_base/packs/ordinary.md +++ b/Config/_base/packs/ordinary.md @@ -6,7 +6,7 @@ Default all-rounder. Handle this turn from the user message + **chat session** c - **Write / improve prompt** → sparse JSON with only changed fields (`prompt`, optional `negative`/`loras`) and `"generate": true` when they want a **new frame**. Chat / «нравится» / Q&A → prose only, **no** JSON. Do **not** `look_at` the last frame first. - **Light critique** → only when they ask to look / critique. Then `look_at: ["generate"]` if pixels are not already in the request. -- **Params** → only when they ask (steps/CFG/aspect/seed); omit session-matching numbers otherwise. +- **Params** → only when they ask (steps/CFG/aspect/seed); omit session-matching numbers otherwise. Exception: if live steps/cfg disagree with Exact/`krea_profile` (e.g. 20/7 under turbo), include Exact profile numbers when generating. - **Inpaint / img2img** → set init/mask fields when they ask. - Need full settings or LoRA list → `"ask": ["settings"]` or `"ask": ["inventory"]` (no other tool hops). diff --git a/README.md b/README.md index ab35700..ceb59c3 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.14.1** — On Generate, force Exact turbo/raw params when live Swarm still has foreign leftovers (e.g. steps 20 / cfg 7). `detectKreaProfileName` no longer invents turbo for unlabeled ckpts (realismByStableYogi → raw). Live context injects `krea_profile` + `recommended_params`. RU «шаг/шагами» counts as param intent. Soft sparse-prompt exception when session ≠ Exact profile. + +**Version 0.14.1** — Generate always merges Exact turbo|raw `steps`/`cfg`/`sigma_shift` (client-authoritative; sparse LLM omit is safe). Park/warm skip no-op Ollama round-trips when the chat model is already (un)loaded via `/api/ps`. Builds on 0.14.0. + **Version 0.14.0** — **Чат = сессия генерации**: у каждого чата свои params/LoRA/checkpoint/кадр/refs; модель шлёт sparse-дельту + `generate`/`look_at`/`ask`; без вкладки Карточки и Civitai/wanted hops. **Сжатие контекста**: rolling-саммари той же Ollama-моделью, чип бюджета `N / num_ctx`, авто перед отправкой, `/compress`. **Version 0.13.1** — Сборка 0.13.0: `using` для `WebSocket`/`HttpClient`, instance-методы с `Config`/`FilePath`, Sqlite dll рядом с extension (иначе вкладка не грузится / API пустые). @@ -92,7 +96,7 @@ Copy `personas/leonid/` → new id, edit only differing JSON. See `Config/person - `Config/_base/exact.json` — canonical generation defaults, profiles (turbo/raw), aspect table, short facts - Persona / disk overlays merge via DeepMerge (matching keys overwrite) -- Always injected into the system prompt; UI fills **empty** SwarmUI fields from Exact (no LLM call) +- Always injected into the system prompt; UI fills **empty** SwarmUI fields from Exact (no LLM call). On **Generate**, the client also **overwrites** non-empty leftovers for `steps` / `cfg` / `sigma_shift` with Exact turbo|raw for the live checkpoint (unless the user asked for different numbers this turn). - Chat-session overrides (`session_exact`) last until persona change or clear chat — not written to disk - Priority: core → user message → **About the user** → session_exact → exact (+ persona) → live fields → craft `memory_hits` @@ -124,8 +128,9 @@ Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared): - `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights. ## VRAM handover -- Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default off -- After Generate the chat model is **always** force-warmed (`AssistentWarmLlm`) — Krea still often evicts VL from VRAM even without park +- Before Generate the chat model is unloaded only if **Park LLM** is enabled (`keep_alive: 0`) — default **off** (VL reload is often 30–120s; enable only when Generate OOMs) +- Park is a no-op when Ollama `/api/ps` already shows the chat model unloaded +- After Generate the chat model is force-warmed (`AssistentWarmLlm`, `keep_alive: 15m`) so the next chat turn is not a surprise cold load — **but** warm is a no-op when `/api/ps` already lists the model (skips the ~20–30s reload when Krea did not evict VL) - Embed / memory models are never parked — reloading them would stall every retrieve ## UX diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 2a1ab4d..8bc71bd 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.14.0"; + Version = "0.14.1"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"]; } @@ -98,7 +98,7 @@ public partial class SwarmAssistentExtension : Extension API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse); API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse); API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse); - Logs.Init("Swarm Assistent extension loaded (0.14.0 chat session)"); + Logs.Init("Swarm Assistent extension loaded (0.14.1 Exact force / krea_profile)"); } int CfgInt(string key, int fallback) diff --git a/Tabs/Text2Image/Assistent.html b/Tabs/Text2Image/Assistent.html index 852b1a4..9b8dff8 100644 --- a/Tabs/Text2Image/Assistent.html +++ b/Tabs/Text2Image/Assistent.html @@ -299,7 +299,7 @@ - +
Скилы (процедуры)
diff --git a/package.json b/package.json index 9efeec8..eba8562 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "node scripts/build.mjs", "watch": "node scripts/build.mjs --watch", - "test": "node --test test/intent.test.js test/patch.test.js test/context.test.js" + "test": "node --test test/intent.test.js test/patch.test.js test/context.test.js test/kreaProfile.test.js" }, "devDependencies": { "esbuild": "^0.25.0" diff --git a/src/app.js b/src/app.js index eed82bb..747c1af 100644 --- a/src/app.js +++ b/src/app.js @@ -571,6 +571,8 @@ } } + let lastExactForceCkpt = null; + function updateGate() { const ok = isKreaSelected(); const gate = $('sa_gate'); @@ -595,6 +597,21 @@ if (layout) { layout.classList.toggle('sa-disabled', !ok); } + // On Krea checkpoint change: align Swarm leftovers to Exact profile once. + try { + const ckptName = resolveCurrentCheckpoint()?.name || null; + if (ok && ckptName && ckptName !== lastExactForceCkpt) { + const defs = typeof exactProfileDefaults === 'function' + ? exactProfileDefaults(detectKreaProfileName()) + : null; + if (defs && defs.steps != null && defs.cfg != null) { + lastExactForceCkpt = ckptName; + forceExactParamsForGenerate({}); + } + } else if (!ok) { + lastExactForceCkpt = null; + } + } catch (e) { /* ignore */ } return ok; } @@ -923,7 +940,10 @@ if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) { return true; } - return cyrTokenRe('размер|ширин[а-яё]*|высот[а-яё]*|соотношен[а-яё]*|турбо|портрет|вертикал[а-яё]*').test(t); + return cyrTokenRe( + 'размер|ширин[а-яё]*|высот[а-яё]*|соотношен[а-яё]*|турбо|портрет|вертикал[а-яё]*|' + + 'шаг|шагом|шагами|шагов|шага', + ).test(t); } function replyMissingJsonPatch(reply) { @@ -1252,21 +1272,123 @@ } function detectKreaProfileName() { + const KP = window.SA?.kreaProfile; try { const model = resolveCurrentCheckpoint(); - const blob = `${model?.name || ''} ${model?.title || ''}`.toLowerCase(); - const hasRaw = /\braw\b/.test(blob); - const hasTurbo = /\bturbo\b/.test(blob); - return hasRaw && !hasTurbo ? 'raw' : 'turbo'; + const blob = `${model?.name || ''} ${model?.title || ''}`; + const exactProfile = state.exact?.generation?.profile; + if (KP?.detectKreaProfileName) { + return KP.detectKreaProfileName(blob, exactProfile); + } + const lower = blob.toLowerCase(); + if (/turbo/.test(lower)) { + return 'turbo'; + } + if (/\braw\b|_raw\b|-raw\b/.test(lower)) { + return 'raw'; + } + // Unlabeled ckpt (e.g. realismByStableYogi) — never invent turbo. + return 'raw'; } catch (e) { - return (state.exact?.generation?.profile) || 'turbo'; + return (state.exact?.generation?.profile) || 'raw'; } } + /** Exact turbo/raw numbers only (no session overlay) — used to force stale SD leftovers. */ + function exactProfileDefaults(profileName) { + const { exact, profiles } = resolveExactBundle(); + const profile = profileName || detectKreaProfileName(); + const KP = window.SA?.kreaProfile; + if (KP?.profileParamDefaults) { + return KP.profileParamDefaults(profiles, profile, exact.generation); + } + const gen = exact.generation && typeof exact.generation === 'object' ? exact.generation : {}; + const fromProfile = profiles[profile] && typeof profiles[profile] === 'object' ? profiles[profile] : {}; + return { + profile, + steps: fromProfile.steps ?? gen.steps ?? null, + cfg: fromProfile.cfg ?? gen.cfg ?? null, + sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null, + }; + } + + function readLiveStepsCfgSigma() { + return { + steps: parseInt(val('input_steps') || '', 10) || null, + cfg: parseFloat(val('input_cfgscale') || val('input_cfg') || '') || null, + sigma_shift: parseFloat(val('input_sigmashift') || '') || null, + }; + } + + function setCfgVal(n) { + if (document.getElementById('input_cfgscale')) { + setVal('input_cfgscale', String(n)); + } else if (document.getElementById('input_cfg')) { + setVal('input_cfg', String(n)); + } + } + + /** + * P0: before Generate, overwrite foreign SD-like leftovers (e.g. 20/7) with Exact + * turbo 8/1/σ1.15 or raw 28/4.5. Always honors explicit patch keys; sessionExact + * only when the user asked for params this turn. + */ + function forceExactParamsForGenerate({ patch = null, profileName = null } = {}) { + if (typeof isKreaSelected === 'function' && !isKreaSelected()) { + return false; + } + const profile = profileName || detectKreaProfileName(); + const defaults = exactProfileDefaults(profile); + const live = readLiveStepsCfgSigma(); + const KP = window.SA?.kreaProfile; + const userIntent = !!state.lastUserParamIntent; + const keys = KP?.exactKeysToForce + ? KP.exactKeysToForce(live, defaults, { + patch, + sessionExact: state.sessionExact, + userParamIntent: userIntent, + }) + : ['steps', 'cfg', 'sigma_shift'].filter((k) => { + const want = defaults[k]; + if (want == null) { + return false; + } + if (patch?.[k] != null && String(patch[k]) !== String(want)) { + return false; + } + if (userIntent && state.sessionExact?.[k] != null) { + return false; + } + return live[k] == null || String(live[k]) !== String(want); + }); + if (!keys.length) { + return false; + } + for (const key of keys) { + const want = defaults[key]; + if (key === 'steps') { + setVal('input_steps', String(want)); + } else if (key === 'cfg') { + setCfgVal(want); + } else if (key === 'sigma_shift') { + setVal('input_sigmashift', String(want)); + } + if (state.sessionExact && state.sessionExact[key] != null + && String(state.sessionExact[key]) !== String(want)) { + delete state.sessionExact[key]; + } + if (state.chatSession?.gen) { + state.chatSession.gen[key] = want; + } + } + syncLiveParamsBar(); + return true; + } + function mergedGenerationDefaults(profileName) { const { exact, profiles } = resolveExactBundle(); const gen = exact.generation && typeof exact.generation === 'object' ? { ...exact.generation } : {}; - const profile = profileName || gen.profile || detectKreaProfileName(); + const profile = profileName || detectKreaProfileName(); const fromProfile = profiles[profile] && typeof profiles[profile] === 'object' ? { ...profiles[profile] } : {}; const session = state.sessionExact && typeof state.sessionExact === 'object' ? { ...state.sessionExact } : {}; // Profile (turbo/raw) overrides generation defaults; session overrides both. @@ -1313,6 +1435,42 @@ return String(value) !== String(exactVal); } + /** + * Client-authoritative Exact fill for Generate: inject turbo/raw steps/cfg/sigma + * when the sparse patch omitted them, and drop stale sessionExact that would + * block applyPatch (e.g. leftover SD-like 20/7 after a turbo checkpoint). + */ + function ensureExactParamsForGenerate(patch) { + const S = window.SA && window.SA.session; + if (!patch || !S || typeof S.mergeExactParamsForGenerate !== 'function') { + return patch; + } + if (!S.patchWantsGenerate(patch)) { + return patch; + } + const { exact, profiles } = resolveExactBundle(); + const { patch: next, clearSessionKeys } = S.mergeExactParamsForGenerate(patch, { + exact, + profiles, + profileName: detectKreaProfileName(), + sessionExact: state.sessionExact, + userParamIntent: !!state.lastUserParamIntent, + }); + for (const key of clearSessionKeys || []) { + if (state.sessionExact && state.sessionExact[key] != null) { + delete state.sessionExact[key]; + } + } + if (state.chatSession && state.chatSession.gen) { + for (const key of S.EXACT_GENERATE_PARAM_KEYS || ['steps', 'cfg', 'sigma_shift']) { + if (next[key] != null) { + state.chatSession.gen[key] = next[key]; + } + } + } + return next; + } + function fillEmptyParamsFromExact() { const defaults = mergedGenerationDefaults(); if (isEmptyParamField(val('input_steps'), { treatZeroEmpty: true }) && defaults.steps != null) { @@ -3831,6 +3989,8 @@ const S = window.SA && window.SA.session; let initCtx = {}; try { if (typeof readInitContext === 'function') initCtx = readInitContext(); } catch (e) { /* ignore */ } + const kreaProfile = detectKreaProfileName(); + const exactDefs = exactProfileDefaults(kreaProfile); const extra = { prompt_image_count: typeof countPromptImages === 'function' ? countPromptImages() : 0, has_vision_image: typeof visionReadySlots === 'function' ? visionReadySlots().length > 0 : false, @@ -3838,6 +3998,12 @@ attached_slot_ids: typeof attachableSlots === 'function' ? attachableSlots().map((s) => s.id) : [], auto_apply: !!$('sa_auto_apply')?.checked, auto_generate: !false /* auto_generate ignored 0.14 */, + krea_profile: kreaProfile, + recommended_params: { + steps: exactDefs.steps, + cfg: exactDefs.cfg, + sigma_shift: exactDefs.sigma_shift, + }, ...initCtx, }; if (S && typeof S.compactContext === 'function') { @@ -3853,7 +4019,7 @@ return ctx; } const g = (state.chatSession && state.chatSession.gen) || {}; - return { session: true, prompt: g.prompt || '', negative: g.negative || '', ...extra }; + return { session: true, prompt: g.prompt || '', negative: g.negative || '', krea_profile: kreaProfile, ...extra }; } function slimInventoryLoras(list, limit) { const selected = new Set(); @@ -4188,6 +4354,11 @@ if (!patch) { return; } + const S = window.SA && window.SA.session; + const wantsGen = S && typeof S.patchWantsGenerate === 'function' && S.patchWantsGenerate(patch); + if (wantsGen) { + patch = ensureExactParamsForGenerate(patch); + } const doPrompt = !which || which === 'all' || which === 'prompt'; const doLoras = !which || which === 'all' || which === 'loras'; const doParams = !which || which === 'all' || which === 'size' || which === 'params'; @@ -4372,6 +4543,12 @@ setVal(batchId, String(defBatch)); } } + // Generate: force Exact profile numbers when live still has foreign leftovers (20/7). + const Sgen = window.SA && window.SA.session; + if (Sgen?.patchWantsGenerate?.(patch) || patch.generate === true + || (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'))) { + forceExactParamsForGenerate({ patch }); + } } if (doInit) { @@ -4795,17 +4972,53 @@ } async function runGenerateFromPatch(patch, opts = {}) { + // Capture the model/user delta before live UI pollutes Exact keys. + const deltaBeforeLive = patch && typeof patch === 'object' ? { ...patch } : {}; + const S = window.SA && window.SA.session; + const exactKeys = (S && S.EXACT_GENERATE_PARAM_KEYS) || ['steps', 'cfg', 'sigma_shift']; + // 0.14: session is source of truth for Generate if (typeof pullLiveIntoSession === 'function') pullLiveIntoSession(); - if (state.chatSession && state.chatSession.gen && (opts.fromSession || opts.force)) { - const fromSess = { ...state.chatSession.gen, generate: true, actions: ['generate'] }; - if (patch && typeof patch === 'object') { - for (const k of Object.keys(patch)) { - if (patch[k] != null) fromSess[k] = patch[k]; + + // Live UI often still holds SD-like leftovers (steps 20 / cfg 7). Drop those + // Exact keys from the session unless this delta or the user explicitly set them. + if (state.chatSession && state.chatSession.gen) { + for (const key of exactKeys) { + const fromDelta = deltaBeforeLive[key] != null; + const fromUser = !!state.lastUserParamIntent && state.sessionExact?.[key] != null; + if (!fromDelta && !fromUser) { + state.chatSession.gen[key] = null; } } - patch = fromSess; + } + + let working = { + ...deltaBeforeLive, + generate: true, + actions: Array.isArray(deltaBeforeLive.actions) + && deltaBeforeLive.actions.map(String).includes('generate') + ? deltaBeforeLive.actions + : [...(Array.isArray(deltaBeforeLive.actions) ? deltaBeforeLive.actions : []), 'generate'], + }; + working = ensureExactParamsForGenerate(working); + + if (state.chatSession && state.chatSession.gen && (opts.fromSession || opts.force)) { + const fromSess = { ...state.chatSession.gen, generate: true, actions: ['generate'] }; + for (const k of Object.keys(working)) { + if (working[k] != null) fromSess[k] = working[k]; + } + // Re-clear Exact keys that still came only from stale live snapshot. + for (const key of exactKeys) { + const fromDelta = deltaBeforeLive[key] != null; + const fromUser = !!state.lastUserParamIntent && state.sessionExact?.[key] != null; + if (!fromDelta && !fromUser && working[key] != null) { + fromSess[key] = working[key]; + } + } + patch = ensureExactParamsForGenerate(fromSess); if (typeof pushSessionToSwarm === 'function') await pushSessionToSwarm(state.chatSession); + } else { + patch = working; } const force = !!opts.force; @@ -4871,6 +5084,11 @@ syncLiveParamsBar(); } ensureNegativeForGenerate(job.patch); + // P0: never Generate with leftover 20/7 under a turbo/raw Exact profile. + forceExactParamsForGenerate({ patch: job.patch }); + if (typeof pullLiveIntoSession === 'function') { + pullLiveIntoSession(); + } if (epoch !== state.chatEpoch) { break; @@ -5817,7 +6035,12 @@ if (!(meta && meta.historical)) { fillKnobsFromConfig(data); updateCtxChip(); if (applyDefaults || data.exact) { + lastExactForceCkpt = null; fillEmptyParamsFromExact(); + if (typeof isKreaSelected === 'function' && isKreaSelected()) { + forceExactParamsForGenerate({}); + lastExactForceCkpt = resolveCurrentCheckpoint()?.name || null; + } } const nextPersona = data.persona || $('sa_persona')?.value || ''; let controlValues = data.control_values || data.exact?.controls || {}; @@ -7531,6 +7754,15 @@ if (!(meta && meta.historical)) { status: intent.vetoed ? 'skip' : 'running', }); if (typeof startBusyUi === 'function') startBusyUi('silent_gen'); + if (effective) { + effective = ensureExactParamsForGenerate({ + ...effective, + generate: true, + actions: Array.isArray(effective.actions) && effective.actions.map(String).includes('generate') + ? effective.actions + : [...(Array.isArray(effective.actions) ? effective.actions : []), 'generate'], + }); + } if (S && effective) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective); await pushSessionToSwarm(state.chatSession); if (typeof syncLiveParamsBar === 'function') syncLiveParamsBar(); @@ -7554,13 +7786,16 @@ if (!(meta && meta.historical)) { state.pendingSilentGen = false; } async function applyQuickPatch(patch, note) { - const withActions = { ...patch }; + let withActions = { ...patch }; if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) { withActions.actions = ['generate']; } const prevIntent = state.lastUserParamIntent; state.lastUserParamIntent = true; const S = window.SA && window.SA.session; + if (S && S.patchWantsGenerate && S.patchWantsGenerate(withActions)) { + withActions = ensureExactParamsForGenerate(withActions); + } if (S) { state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), withActions); } diff --git a/src/kreaProfile.js b/src/kreaProfile.js new file mode 100644 index 0000000..b765875 --- /dev/null +++ b/src/kreaProfile.js @@ -0,0 +1,108 @@ +/** + * Krea turbo/raw profile detection + Exact param alignment helpers (pure). + * Prevents labeling ambiguous checkpoints as turbo while Swarm still has SD-like 20/7. + */ + +export function numClose(a, b, eps = 0.051) { + const x = Number(a); + const y = Number(b); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return false; + } + return Math.abs(x - y) <= eps; +} + +/** + * Detect turbo|raw from checkpoint name/title. + * Names without turbo/raw (e.g. realismByStableYogi) must NOT invent "turbo". + * Note: INT8Turbo has no word-boundary before "turbo" — use substring match. + */ +export function detectKreaProfileName(modelBlob, exactProfile) { + const blob = String(modelBlob || '').toLowerCase(); + const hasTurbo = /turbo/.test(blob); + const hasRaw = /\braw\b|_raw\b|-raw\b|\/raw\b/.test(blob); + if (hasTurbo) { + return 'turbo'; + } + if (hasRaw) { + return 'raw'; + } + // Unlabeled ckpt — Exact generation.profile alone must not invent turbo. + void exactProfile; + return 'raw'; +} + +/** Profile-only defaults (no session overlay). */ +export function profileParamDefaults(profiles, profileName, generation = {}) { + const profile = String(profileName || 'raw'); + const fromProfile = profiles && typeof profiles[profile] === 'object' ? profiles[profile] : {}; + const gen = generation && typeof generation === 'object' ? generation : {}; + return { + profile, + steps: fromProfile.steps ?? gen.steps ?? null, + cfg: fromProfile.cfg ?? gen.cfg ?? null, + sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null, + }; +} + +/** + * Whether live Swarm numbers already match the Exact profile (steps/cfg/σ). + */ +export function liveMatchesExactProfile(live, defaults) { + if (!defaults || defaults.steps == null || defaults.cfg == null) { + return true; + } + const stepsOk = live?.steps == null || numClose(live.steps, defaults.steps, 0.5); + const cfgOk = live?.cfg == null || numClose(live.cfg, defaults.cfg); + const sigmaOk = defaults.sigma_shift == null + || live?.sigma_shift == null + || numClose(live.sigma_shift, defaults.sigma_shift); + return stepsOk && cfgOk && sigmaOk; +} + +/** + * Keys that should be forced from Exact onto live before Generate. + * - Always rewrite live when it disagrees with Exact profile defaults. + * - Skip only when the patch sets a *different* intentional value, or + * sessionExact holds an override and userParamIntent is true. + * - Stale sessionExact leftovers (SD 20/7) must NOT block Exact. + */ +export function exactKeysToForce(live, defaults, { + patch = null, + sessionExact = null, + userParamIntent = false, +} = {}) { + if (!defaults) { + return []; + } + const out = []; + for (const key of ['steps', 'cfg', 'sigma_shift']) { + const want = defaults[key]; + if (want == null) { + continue; + } + const eps = key === 'steps' ? 0.5 : 0.051; + // Patch asked for a non-Exact number — honor it. + if (patch && patch[key] != null && !numClose(patch[key], want, eps)) { + continue; + } + if (userParamIntent && sessionExact && sessionExact[key] != null) { + continue; + } + const have = live?.[key]; + if (have == null || !numClose(have, want, eps)) { + out.push(key); + } + } + return out; +} + +export function attachKreaProfile(SA) { + SA.kreaProfile = { + numClose, + detectKreaProfileName, + profileParamDefaults, + liveMatchesExactProfile, + exactKeysToForce, + }; +} diff --git a/src/main.js b/src/main.js index 2f64400..0e564ea 100644 --- a/src/main.js +++ b/src/main.js @@ -4,6 +4,7 @@ import { attachPersist } from './persist.js'; import { attachSession } from './session.js'; import { attachContext } from './context.js'; import { attachActivity } from './activity.js'; +import { attachKreaProfile } from './kreaProfile.js'; window.SA = window.SA || {}; attachApi(window.SA); @@ -12,6 +13,7 @@ attachPersist(window.SA); attachSession(window.SA); attachContext(window.SA); attachActivity(window.SA); +attachKreaProfile(window.SA); /** Called from app after AssistentGetConfig — single source: Config/_base/patch-keys.json */ window.SA.applyConfigPatchKeys = function (config) { diff --git a/src/session.js b/src/session.js index 218b1e8..311e04e 100644 --- a/src/session.js +++ b/src/session.js @@ -350,6 +350,64 @@ export function resolveTurnIntent(patch, userText, { vetoFn } = {}) { return { generate, look, vetoed, ask }; } +/** Params the client fills from Exact on Generate when the LLM omits them (sparse contract). */ +export const EXACT_GENERATE_PARAM_KEYS = ['steps', 'cfg', 'sigma_shift']; + +/** + * Turbo/RAW profile numbers from Exact — no sessionExact overlay. + * profileName should already reflect the live checkpoint (turbo vs raw). + */ +export function resolveExactProfileDefaults({ exact, profiles, profileName } = {}) { + const gen = exact?.generation && typeof exact.generation === 'object' ? exact.generation : {}; + const profile = profileName || gen.profile || 'turbo'; + const fromProfile = profiles?.[profile] && typeof profiles[profile] === 'object' + ? profiles[profile] + : {}; + return { + profile, + steps: fromProfile.steps ?? gen.steps ?? null, + cfg: fromProfile.cfg ?? gen.cfg ?? null, + sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null, + }; +} + +/** + * On generate, inject Exact profile steps/cfg/sigma when the patch omitted them. + * Client is authoritative so sparse LLM deltas are safe. Honors explicit patch values + * and sessionExact when the user asked for params this turn. + * Returns clearSessionKeys so the UI apply path is not blocked by stale sessionExact. + */ +export function mergeExactParamsForGenerate(patch, { + exact, + profiles, + profileName, + sessionExact, + userParamIntent, +} = {}) { + if (!patchWantsGenerate(patch)) { + return { patch, clearSessionKeys: [], profile: null }; + } + const defaults = resolveExactProfileDefaults({ exact, profiles, profileName }); + const out = { ...patch }; + const clearSessionKeys = []; + for (const key of EXACT_GENERATE_PARAM_KEYS) { + if (out[key] != null) { + continue; + } + if (userParamIntent && sessionExact?.[key] != null) { + out[key] = sessionExact[key]; + continue; + } + if (defaults[key] != null) { + out[key] = defaults[key]; + if (sessionExact?.[key] != null && String(sessionExact[key]) !== String(defaults[key])) { + clearSessionKeys.push(key); + } + } + } + return { patch: out, clearSessionKeys, profile: defaults.profile }; +} + export function attachSession(SA) { SA.session = { emptySession, @@ -363,6 +421,9 @@ export function attachSession(SA) { compactContext, fullSettingsDump, resolveTurnIntent, + resolveExactProfileDefaults, + mergeExactParamsForGenerate, + EXACT_GENERATE_PARAM_KEYS, GEN_KEYS, }; } diff --git a/test/kreaProfile.test.js b/test/kreaProfile.test.js new file mode 100644 index 0000000..f07a9e5 --- /dev/null +++ b/test/kreaProfile.test.js @@ -0,0 +1,84 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + detectKreaProfileName, + profileParamDefaults, + liveMatchesExactProfile, + exactKeysToForce, + numClose, +} from '../src/kreaProfile.js'; + +const PROFILES = { + turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, + raw: { steps: 28, cfg: 4.5, sigma_shift: 1.15 }, +}; + +describe('kreaProfile', () => { + it('detectKreaProfileName: turbo token wins', () => { + assert.equal(detectKreaProfileName('realismByStableYogi_v25INT8Turbo', 'raw'), 'turbo'); + assert.equal(detectKreaProfileName('Krea2 Turbo fp8', 'raw'), 'turbo'); + }); + + it('detectKreaProfileName: raw token without turbo', () => { + assert.equal(detectKreaProfileName('Krea2 RAW Base', 'turbo'), 'raw'); + }); + + it('detectKreaProfileName: unlabeled Yogi is not fake turbo', () => { + assert.equal(detectKreaProfileName('realismByStableYogi_v25', 'turbo'), 'raw'); + assert.equal(detectKreaProfileName('realismByStableYogi', null), 'raw'); + }); + + it('liveMatchesExactProfile rejects SD leftovers under turbo', () => { + const turbo = profileParamDefaults(PROFILES, 'turbo'); + assert.equal(liveMatchesExactProfile({ steps: 20, cfg: 7, sigma_shift: 1.15 }, turbo), false); + assert.equal(liveMatchesExactProfile({ steps: 8, cfg: 1, sigma_shift: 1.15 }, turbo), true); + }); + + it('exactKeysToForce overwrites 20/7 unless intentional non-Exact patch or user session', () => { + const turbo = profileParamDefaults(PROFILES, 'turbo'); + assert.deepEqual( + exactKeysToForce({ steps: 20, cfg: 7, sigma_shift: 1 }, turbo, {}), + ['steps', 'cfg', 'sigma_shift'], + ); + // Injected Exact values in the patch still force live leftovers. + assert.deepEqual( + exactKeysToForce( + { steps: 20, cfg: 7, sigma_shift: 1 }, + turbo, + { patch: { steps: 8, cfg: 1, sigma_shift: 1.15 } }, + ), + ['steps', 'cfg', 'sigma_shift'], + ); + // Stale sessionExact without user intent must not block Exact. + assert.deepEqual( + exactKeysToForce( + { steps: 20, cfg: 7, sigma_shift: 1.15 }, + turbo, + { sessionExact: { steps: 20 }, userParamIntent: false }, + ), + ['steps', 'cfg'], + ); + assert.deepEqual( + exactKeysToForce( + { steps: 20, cfg: 7, sigma_shift: 1.15 }, + turbo, + { sessionExact: { steps: 20 }, userParamIntent: true }, + ), + ['cfg'], + ); + // Intentional non-Exact patch value is kept. + assert.deepEqual( + exactKeysToForce({ steps: 20, cfg: 7, sigma_shift: 1.15 }, turbo, { patch: { cfg: 7 } }), + ['steps'], + ); + assert.deepEqual( + exactKeysToForce({ steps: 8, cfg: 1, sigma_shift: 1.15 }, turbo, {}), + [], + ); + }); + + it('numClose', () => { + assert.equal(numClose(1.15, 1.14), true); + assert.equal(numClose(1, 7), false); + }); +}); diff --git a/test/patch.test.js b/test/patch.test.js index e1dd880..0f0d852 100644 --- a/test/patch.test.js +++ b/test/patch.test.js @@ -13,6 +13,8 @@ import { sessionFromLegacyParams, toPersistParams, resolveTurnIntent, + mergeExactParamsForGenerate, + resolveExactProfileDefaults, } from '../src/session.js'; describe('patch.js', () => { @@ -74,4 +76,67 @@ describe('session.js', () => { assert.equal(intent.generate, false); assert.equal(intent.vetoed, true); }); + + it('mergeExactParamsForGenerate fills turbo profile when LLM omits steps/cfg', () => { + const exact = { + generation: { profile: 'turbo', steps: 8, cfg: 1, sigma_shift: 1.15 }, + profiles: { + turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, + raw: { steps: 28, cfg: 4.5, sigma_shift: 1.15 }, + }, + }; + const { patch, clearSessionKeys, profile } = mergeExactParamsForGenerate( + { prompt: 'a fox', generate: true }, + { + exact, + profiles: exact.profiles, + profileName: 'turbo', + sessionExact: { steps: 20, cfg: 7 }, + userParamIntent: false, + }, + ); + assert.equal(profile, 'turbo'); + assert.equal(patch.steps, 8); + assert.equal(patch.cfg, 1); + assert.equal(patch.sigma_shift, 1.15); + assert.deepEqual(clearSessionKeys.sort(), ['cfg', 'steps']); + }); + + it('mergeExactParamsForGenerate keeps user-intent sessionExact and explicit patch', () => { + const exact = { + generation: { steps: 8, cfg: 1 }, + profiles: { raw: { steps: 28, cfg: 4.5, sigma_shift: 1.15 } }, + }; + const kept = mergeExactParamsForGenerate( + { generate: true, actions: ['generate'] }, + { + exact, + profiles: exact.profiles, + profileName: 'raw', + sessionExact: { steps: 20 }, + userParamIntent: true, + }, + ); + assert.equal(kept.patch.steps, 20); + assert.equal(kept.patch.cfg, 4.5); + assert.deepEqual(kept.clearSessionKeys, []); + + const explicit = mergeExactParamsForGenerate( + { generate: true, steps: 12, cfg: 2 }, + { exact, profiles: exact.profiles, profileName: 'turbo' }, + ); + assert.equal(explicit.patch.steps, 12); + assert.equal(explicit.patch.cfg, 2); + }); + + it('resolveExactProfileDefaults picks raw over generation defaults', () => { + const d = resolveExactProfileDefaults({ + exact: { generation: { steps: 8, cfg: 1, sigma_shift: 1.15 } }, + profiles: { raw: { steps: 28, cfg: 4.5 } }, + profileName: 'raw', + }); + assert.equal(d.steps, 28); + assert.equal(d.cfg, 4.5); + assert.equal(d.sigma_shift, 1.15); + }); });