Ship Assistent 0.15.2: apply closed generate patches immediately, recover stalled streams, and skip repeat greetings when a chat already has replies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 09:28:04 +03:00
co-authored by Cursor
parent 4b36850e2b
commit e53f483a00
19 changed files with 790 additions and 287 deletions
+262 -122
View File
@@ -43,6 +43,7 @@
"actions",
"generate",
"ask",
"checkpoint",
"use_init_image",
"clear_init_image",
"init_creativity",
@@ -85,6 +86,20 @@
function has(obj, key) {
return obj[key] !== void 0 && obj[key] !== null;
}
function generateFlagOn(obj) {
if (!obj || typeof obj !== "object") {
return false;
}
const g = obj.generate;
if (g === true || g === 1) {
return true;
}
if (typeof g === "string" && /^(true|1|yes|on)$/i.test(g.trim())) {
return true;
}
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
return acts.includes("generate");
}
function isPatchObject2(obj) {
if (!obj || typeof obj !== "object") {
return false;
@@ -96,7 +111,7 @@
return patch;
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
if (patch.generate === true || acts.includes("generate")) {
if (generateFlagOn(patch) || acts.includes("generate")) {
patch.generate = true;
}
if (typeof patch.ask === "string") {
@@ -114,31 +129,63 @@
}
return patch;
}
function tryParsePatchJson(raw) {
try {
const obj = JSON.parse(String(raw || "").trim());
if (isPatchObject2(obj)) {
return normalizePatch(obj);
}
} catch (e) {
}
return null;
}
function extractPatch(text) {
if (!text) {
return { prose: text || "", patch: null };
}
const re = new RegExp(FENCE_RE.source, "gi");
let match;
let lastPatch = null;
let prose = text;
let lastAny = null;
let lastAnyIndex = -1;
let lastAnyLen = 0;
let lastTerminal = null;
let lastTermIndex = -1;
let lastTermLen = 0;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isPatchObject2(obj)) {
lastPatch = normalizePatch(obj);
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
}
} catch (e) {
const parsed = tryParsePatchJson(match[1]);
if (!parsed) {
continue;
}
lastAny = parsed;
lastAnyIndex = match.index;
lastAnyLen = match[0].length;
if (isTerminalStreamPatch(parsed)) {
lastTerminal = parsed;
lastTermIndex = match.index;
lastTermLen = match[0].length;
}
}
return { prose, patch: lastPatch };
const chosen = lastTerminal || lastAny;
if (chosen) {
const idx = lastTerminal ? lastTermIndex : lastAnyIndex;
const len = lastTerminal ? lastTermLen : lastAnyLen;
const prose = (text.slice(0, idx) + text.slice(idx + len)).trim();
return { prose, patch: chosen };
}
const brace = text.lastIndexOf("{");
if (brace >= 0) {
const parsed = tryParsePatchJson(text.slice(brace));
if (parsed) {
return { prose: text.slice(0, brace).trim(), patch: parsed };
}
}
return { prose: text, patch: null };
}
function isTerminalStreamPatch(obj) {
if (!obj || typeof obj !== "object") {
return false;
}
if (obj.generate === true) {
if (generateFlagOn(obj)) {
return true;
}
if (Array.isArray(obj.ask) && obj.ask.length) {
@@ -153,14 +200,10 @@
if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
return true;
}
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
if (acts.includes("generate")) {
return true;
}
if (String(obj.prompt || "").trim().length >= 48) {
return true;
}
if (obj.loras != null || obj.aspect != null || obj.steps != null || obj.width != null || obj.height != null || obj.cfg != null || obj.seed != null || obj.controls != null) {
if (obj.loras != null || obj.aspect != null || obj.steps != null || obj.width != null || obj.height != null || obj.cfg != null || obj.seed != null || obj.controls != null || obj.checkpoint != null || obj.negative != null) {
return true;
}
return false;
@@ -172,6 +215,7 @@
SA2.isTerminalStreamPatch = isTerminalStreamPatch;
SA2.normalizePatch = normalizePatch;
SA2.extractPatch = extractPatch;
SA2.generateFlagOn = generateFlagOn;
}
// src/persist.js
@@ -375,7 +419,9 @@
}
const delta = { ...raw };
const acts = Array.isArray(delta.actions) ? delta.actions.map(String) : [];
if (delta.generate === true || acts.includes("generate")) {
const g = delta.generate;
const generateOn = g === true || g === 1 || typeof g === "string" && /^(true|1|yes|on)$/i.test(g.trim()) || acts.includes("generate");
if (generateOn) {
delta.generate = true;
}
if (typeof delta.ask === "string") {
@@ -392,7 +438,8 @@
if (!patch || typeof patch !== "object") {
return false;
}
if (patch.generate === true) {
const n = normalizeDelta(patch);
if (n?.generate === true) {
return true;
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
@@ -631,10 +678,12 @@
session_exact: extras.sessionExact || null
};
}
function resolveTurnIntent(patch, userText, { vetoFn } = {}) {
function resolveTurnIntent(patch, userText, { vetoFn, askGenerateFn, fromAutoCritique } = {}) {
const delta = normalizeDelta(patch) || {};
const vetoed = typeof vetoFn === "function" ? !!vetoFn(userText) : false;
const generate = !vetoed && patchWantsGenerate(delta);
const modelAsked = patchWantsGenerate(delta);
const userAsked = typeof askGenerateFn === "function" && !!askGenerateFn(userText) && !!(modelAsked || String(delta.prompt || "").trim());
const generate = !vetoed && !fromAutoCritique && (modelAsked || userAsked);
const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null;
const look = !!(hasLook && !generate && !vetoed);
const ask = patchAskList(delta);
@@ -884,7 +933,7 @@
scrollToBottom,
hideEmpty
} = opts;
let card2 = null;
let card = null;
let listEl = null;
let titleEl = null;
let steps = [];
@@ -894,16 +943,16 @@
if (!box) {
return null;
}
if (card2 && card2.isConnected) {
return card2;
if (card && card.isConnected) {
return card;
}
if (typeof hideEmpty === "function") {
hideEmpty();
}
card2 = document.createElement("div");
card2.className = "sa-activity sa-activity-live";
card2.setAttribute("role", "status");
card2.setAttribute("aria-live", "polite");
card = document.createElement("div");
card.className = "sa-activity sa-activity-live";
card.setAttribute("role", "status");
card.setAttribute("aria-live", "polite");
const head = document.createElement("button");
head.type = "button";
head.className = "sa-activity-head";
@@ -923,18 +972,18 @@
head.appendChild(chev);
head.addEventListener("click", () => {
open = !open;
card2.classList.toggle("sa-activity-collapsed", !open);
card.classList.toggle("sa-activity-collapsed", !open);
head.setAttribute("aria-expanded", open ? "true" : "false");
});
listEl = document.createElement("div");
listEl.className = "sa-activity-steps";
card2.appendChild(head);
card2.appendChild(listEl);
box.appendChild(card2);
card.appendChild(head);
card.appendChild(listEl);
box.appendChild(card);
if (typeof scrollToBottom === "function") {
scrollToBottom();
}
return card2;
return card;
}
function renderStep(step) {
const row = document.createElement("div");
@@ -970,15 +1019,15 @@
if (titleEl) {
titleEl.textContent = running ? running.label : last?.label || "Assistent";
}
card2.classList.toggle("sa-activity-live", steps.some((s) => s.status === "running"));
card2.classList.toggle("sa-activity-done", steps.length > 0 && steps.every((s) => s.status === "done" || s.status === "skip"));
card.classList.toggle("sa-activity-live", steps.some((s) => s.status === "running"));
card.classList.toggle("sa-activity-done", steps.length > 0 && steps.every((s) => s.status === "done" || s.status === "skip"));
if (typeof scrollToBottom === "function") {
scrollToBottom();
}
}
function begin(title) {
steps = [];
card2 = null;
card = null;
listEl = null;
titleEl = null;
open = true;
@@ -1021,9 +1070,9 @@
titleEl.textContent = summary;
}
paint();
if (card2) {
card2.classList.remove("sa-activity-live");
card2.classList.add("sa-activity-done");
if (card) {
card.classList.remove("sa-activity-live");
card.classList.add("sa-activity-done");
}
}
function noteModelCommands(patch) {
@@ -1242,8 +1291,8 @@
\u041D\u0430\u043F\u0438\u0448\u0438, \u0447\u0442\u043E \u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u2014 \u0438\u043B\u0438 \u043A\u0438\u043D\u044C \u0440\u0435\u0444\u0435\u0440\u0435\u043D\u0441 \u0438 \u043F\u043E\u043F\u0440\u043E\u0441\u0438 \u043F\u0440\u0430\u0432\u043A\u0443.`;
let HELP_TEXT = `Slash-\u043A\u043E\u043C\u0430\u043D\u0434\u044B (\u0431\u0435\u0437 LLM):
/help \u2014 \u044D\u0442\u043E\u0442 \u0441\u043F\u0438\u0441\u043E\u043A
/new \u2014 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442 (\u0442\u0435\u043A\u0443\u0449\u0438\u0439 \u0441\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u0441\u044F \u0432 \u0418\u0441\u0442\u043E\u0440\u0438\u044E)
/history \u2014 \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u0441\u043F\u0438\u0441\u043E\u043A \u0447\u0430\u0442\u043E\u0432
/new \u2014 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442 (\u0442\u0435\u043A\u0443\u0449\u0438\u0439 \u0441\u043E\u0445\u0440\u0430\u043D\u0438\u0442\u0441\u044F \u0432 \u0441\u043F\u0438\u0441\u043A\u0435)
/history \u2014 \u043E\u0442\u043A\u0440\u044B\u0442\u044C \u0438\u043B\u0438 \u0441\u043A\u0440\u044B\u0442\u044C \u043F\u0430\u043D\u0435\u043B\u044C \u0447\u0430\u0442\u043E\u0432
/compress \u2014 \u0441\u0436\u0430\u0442\u044C \u0441\u0442\u0430\u0440\u044B\u0435 \u0445\u043E\u0434\u044B \u0432 \u0441\u0430\u043C\u043C\u0430\u0440\u0438
/debug \u2014 \u0441\u0432\u043E\u0434\u043A\u0430 UI/Exact (\u0431\u0435\u0437 LLM)
/debug ask \u2014 \u0442\u043E \u0436\u0435 + \u043A\u043E\u0440\u043E\u0442\u043A\u0438\u0439 \u043E\u0442\u0432\u0435\u0442 \u043C\u043E\u0434\u0435\u043B\u0438
@@ -1259,11 +1308,11 @@
/inventory \u2014 rescan \u043C\u043E\u0434\u0435\u043B\u0435\u0439 + \u043E\u0431\u043D\u043E\u0432\u0438\u0442\u044C \u0441\u043F\u0438\u0441\u043E\u043A LoRA
\u0427\u0438\u043F\u0441\u044B \u043D\u0430\u0434 \u043F\u043E\u043B\u0435\u043C \u0432\u0432\u043E\u0434\u0430 \u0434\u0435\u043B\u0430\u044E\u0442 \u0442\u043E \u0436\u0435 \u0434\u043B\u044F aspect / seed / vary / Turbo\xB7RAW.
\u041F\u0440\u0438 \u0441\u0442\u0430\u0440\u0442\u0435 \u0432\u0441\u0435\u0433\u0434\u0430 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442; \u0441\u043C\u0435\u043D\u0430 \u0447\u0430\u0442\u0430 \u0432 \u0418\u0441\u0442\u043E\u0440\u0438\u0438 \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u0430\u0432\u043B\u0438\u0432\u0430\u0435\u0442 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B.`;
\u041F\u0440\u0438 \u0441\u0442\u0430\u0440\u0442\u0435 \u0432\u0441\u0435\u0433\u0434\u0430 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442; \u0441\u043C\u0435\u043D\u0430 \u0447\u0430\u0442\u0430 \u0432 \u043F\u0430\u043D\u0435\u043B\u0438 \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u0430\u0432\u043B\u0438\u0432\u0430\u0435\u0442 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B.`;
let SLASH_COMMANDS = [
{ cmd: "/help", hint: "\u0441\u043F\u0438\u0441\u043E\u043A \u043A\u043E\u043C\u0430\u043D\u0434" },
{ cmd: "/new", hint: "\u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442" },
{ cmd: "/history", hint: "\u0438\u0441\u0442\u043E\u0440\u0438\u044F \u0447\u0430\u0442\u043E\u0432" },
{ cmd: "/history", hint: "\u043F\u0430\u043D\u0435\u043B\u044C \u0447\u0430\u0442\u043E\u0432" },
{ cmd: "/compress", hint: "\u0441\u0436\u0430\u0442\u044C \u0441\u0442\u0430\u0440\u044B\u0435 \u0445\u043E\u0434\u044B" },
{ cmd: "/debug", hint: "\u0441\u0432\u043E\u0434\u043A\u0430 \xB7 ask = \u0441 LLM" },
{ cmd: "/why", hint: "debug + \u043F\u043E\u044F\u0441\u043D\u0435\u043D\u0438\u0435 LLM" },
@@ -2143,9 +2192,16 @@ ${patch.prompt}`;
function mergePromptEnRewrite(effective) {
const base = state.pendingPromptEnMerge;
state.pendingPromptEnMerge = null;
if (!base || !effective) {
if (!base) {
return effective;
}
if (!effective) {
return {
...base,
generate: true,
actions: Array.isArray(base.actions) && base.actions.length ? base.actions : ["generate"]
};
}
return {
...base,
...effective,
@@ -2184,6 +2240,21 @@ ${patch.prompt}`;
}
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
}
function userAsksGenerate(text) {
if (window.SA && typeof SA.userAsksGenerate === "function") {
return SA.userAsksGenerate(text);
}
const t = String(text || "").trim();
if (!t || userAsksNoGenerate(t)) {
return false;
}
if (/\b(generat(e|ion)|draw|render|make\s+(an?\s+)?image|run\s+generate)\b/i.test(t)) {
return true;
}
return cyrTokenRe(
"\u0441\u0433\u0435\u043D\u0435\u0440[\u0430-\u044F\u0451]*|\u043D\u0430\u0440\u0438\u0441\u0443\u0439|\u043D\u0430\u0440\u0438\u0441\u0443\u0439\u0442\u0435|\u0437\u0430\u043F\u0443\u0441\u0442\u0438\\s+\u0433\u0435\u043D\u0435\u0440[\u0430-\u044F\u0451]*|\u0441\u0434\u0435\u043B\u0430\u0439\\s+(\u043A\u0430\u0434\u0440|\u043A\u0430\u0440\u0442\u0438\u043D\u043A[\u0430-\u044F\u0451]*|\u0438\u0437\u043E\u0431\u0440\u0430\u0436[\u0430-\u044F\u0451]*)"
).test(t);
}
function packWantsVision(pack) {
const p = String(pack || "");
return p === "critique_image" || p === "describe_ref" || p === "compose_scene" || p === "inpaint_edit";
@@ -2191,11 +2262,16 @@ ${patch.prompt}`;
function resolveTurnIntent2(patch, userText, opts = {}) {
const S = window.SA && window.SA.session;
if (S && typeof S.resolveTurnIntent === "function") {
return S.resolveTurnIntent(patch, userText, { vetoFn: userAsksNoGenerate });
return S.resolveTurnIntent(patch, userText, {
vetoFn: userAsksNoGenerate,
askGenerateFn: userAsksGenerate,
fromAutoCritique: !!opts.fromAutoCritique
});
}
const vetoed = !isMachineTurn(opts) && userAsksNoGenerate(userText);
const modelAsked = !!(patch && (patch.generate === true || Array.isArray(patch.actions) && patch.actions.map(String).includes("generate")));
const generate = !vetoed && !opts.fromAutoCritique && modelAsked;
const modelAsked = !!(patch && (patch.generate === true || patch.generate === 1 || typeof patch.generate === "string" && /^(true|1|yes|on)$/i.test(patch.generate) || Array.isArray(patch.actions) && patch.actions.map(String).includes("generate")));
const userAsked = !isMachineTurn(opts) && userAsksGenerate(userText) && !!(modelAsked || String(patch?.prompt || "").trim());
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked);
const hasLook = !!patch && (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const look = !!(hasLook && !vetoed && !generate);
const ask = Array.isArray(patch?.ask) ? patch.ask.map(String) : typeof patch?.ask === "string" && patch.ask ? [patch.ask] : [];
@@ -3364,6 +3440,7 @@ ${patch.prompt}`;
genBtn.textContent = "\u0421\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C";
genBtn.addEventListener("click", async () => {
if (isGenerateUnavailable()) {
setStatus("Generate \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D \u2014 \u043F\u043E\u0434\u043E\u0436\u0434\u0438 \u0438\u043B\u0438 \u043D\u0430\u0436\u043C\u0438 \u0421\u0442\u043E\u043F");
return;
}
startBusyUi("silent_gen");
@@ -4161,7 +4238,7 @@ ${patch.prompt}`;
const empty = document.createElement("div");
empty.className = "sa-chat-empty";
empty.id = "sa_chat_empty";
empty.innerHTML = emptyHint || '<div class="sa-chat-empty-title">\u041D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442</div><div class="sa-chat-empty-hint">\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B Generate \u043E\u0441\u0442\u0430\u044E\u0442\u0441\u044F \u043A\u0430\u043A \u0441\u0435\u0439\u0447\u0430\u0441.<br><strong>+</strong> \u2014 \u0435\u0449\u0451 \u043E\u0434\u0438\u043D \u0447\u0430\u0442 \xB7 <strong>\u0418\u0441\u0442\u043E\u0440\u0438\u044F</strong> \u2014 \u0432\u0435\u0440\u043D\u0443\u0442\u044C\u0441\u044F \u043A \u043F\u0440\u043E\u0448\u043B\u043E\u043C\u0443 (\u0441 \u0435\u0433\u043E \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u0430\u043C\u0438).</div>';
empty.innerHTML = emptyHint || '<div class="sa-chat-empty-title">\u041D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442</div><div class="sa-chat-empty-hint">\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B Generate \u043E\u0441\u0442\u0430\u044E\u0442\u0441\u044F \u043A\u0430\u043A \u0441\u0435\u0439\u0447\u0430\u0441.<br><strong>+</strong> \u2014 \u0435\u0449\u0451 \u043E\u0434\u0438\u043D \u0447\u0430\u0442 \xB7 \u043A\u043D\u043E\u043F\u043A\u0430 \u043F\u0430\u043D\u0435\u043B\u0438 \u0441\u043B\u0435\u0432\u0430 \u2014 \u043F\u0440\u043E\u0448\u043B\u044B\u0435 \u0434\u0438\u0430\u043B\u043E\u0433\u0438.</div>';
box.appendChild(empty);
}
function renderHistoryIntoUi(messages) {
@@ -4222,19 +4299,34 @@ ${patch.prompt}`;
}
const chat = findChat(state.activeChatId);
el.textContent = chat?.title || "\u041D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442";
el.title = (chat?.title || "\u041D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442") + " \u2014 \u043A\u043B\u0438\u043A: \u0418\u0441\u0442\u043E\u0440\u0438\u044F";
el.title = chat?.title || "\u041D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442";
}
function chatHasTranscript(c) {
return (c?.messages || []).length > 0 || (c?.messages_count || 0) > 0;
}
function savedChatsCount() {
return (state.chats || []).filter((c) => (c.messages || []).length > 0).length;
return (state.chats || []).filter(chatHasTranscript).length;
}
function isBlankActiveChat() {
if ((state.history || []).length) {
return false;
}
const chat = findChat(state.activeChatId);
return !chat || !chatHasTranscript(chat);
}
function syncHistoryBadge() {
const btn = $2("sa_btn_chats");
if (!btn) {
return;
const countEl = $2("sa_chats_count");
const n = (state.chats || []).filter(chatHasTranscript).length;
const open = !!state.chatsPanelOpen;
if (btn) {
btn.title = open ? n ? `\u0421\u043A\u0440\u044B\u0442\u044C \u0447\u0430\u0442\u044B (${n})` : "\u0421\u043A\u0440\u044B\u0442\u044C \u0447\u0430\u0442\u044B" : n ? `\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u0447\u0430\u0442\u044B (${n})` : "\u041F\u043E\u043A\u0430\u0437\u0430\u0442\u044C \u0447\u0430\u0442\u044B";
btn.setAttribute("aria-label", btn.title);
}
if (countEl) {
countEl.hidden = n < 1;
countEl.textContent = n > 99 ? "99+" : String(n);
}
const n = savedChatsCount();
btn.textContent = n > 0 ? `\u0418\u0441\u0442\u043E\u0440\u0438\u044F (${n})` : "\u0418\u0441\u0442\u043E\u0440\u0438\u044F";
btn.title = n > 0 ? `\u0421\u043E\u0445\u0440\u0430\u043D\u0451\u043D\u043D\u044B\u0445 \u0447\u0430\u0442\u043E\u0432: ${n}. \u041F\u0435\u0440\u0435\u043A\u043B\u044E\u0447\u0435\u043D\u0438\u0435 \u0432\u043E\u0441\u0441\u0442\u0430\u043D\u0430\u0432\u043B\u0438\u0432\u0430\u0435\u0442 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B.` : "\u0418\u0441\u0442\u043E\u0440\u0438\u044F \u0447\u0430\u0442\u043E\u0432 (\u043F\u043E\u043A\u0430 \u043F\u0443\u0441\u0442\u043E)";
}
function formatChatWhen(ts) {
if (!ts) {
@@ -4304,7 +4396,7 @@ ${patch.prompt}`;
root.innerHTML = "";
syncHistoryBadge();
const q = (state.chatsQuery || "").trim().toLowerCase();
let chats = (state.chats || []).slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
let chats = (state.chats || []).slice().sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0)).filter((c) => c.id === state.activeChatId || chatHasTranscript(c));
if (q) {
const local = chats.filter((c) => chatMatchesQuery(c, q));
const seen = new Set(local.map((c) => c.id));
@@ -4342,6 +4434,7 @@ ${patch.prompt}`;
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");
syncHistoryBadge();
if (state.chatsPanelOpen) {
saveActiveChatToStore();
const search = $2("sa_chats_search");
@@ -4353,13 +4446,17 @@ ${patch.prompt}`;
}
saveUiStateToDisk();
}
async function startNewChat({ saveCurrent = true, force = false } = {}) {
if (!force && (state.busy || state.generating)) {
setStatus("\u0417\u0430\u043D\u044F\u0442\u043E \u2014 \u0434\u043E\u0436\u0434\u0438\u0441\u044C \u043A\u043E\u043D\u0446\u0430 \u043E\u0442\u0432\u0435\u0442\u0430 \u0438\u043B\u0438 \u0421\u0442\u043E\u043F");
async function startNewChat({ saveCurrent = true, force = false, openDrawer = false } = {}) {
if (!force && isBlankActiveChat()) {
setStatus("\u0423\u0436\u0435 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442");
if (openDrawer) {
setChatsPanelOpen(true);
}
$2("sa_input")?.focus();
return;
}
if (force) {
abortInFlightWork({ status: "" });
if (state.busy || state.generating) {
abortInFlightWork({ status: "", interruptSwarm: !!state.generating });
}
if (saveCurrent) {
saveActiveChatToStore({ dropEmpty: true });
@@ -4401,6 +4498,12 @@ ${patch.prompt}`;
updateCtxChip();
setStatus("\u041D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442 \u2014 \u043F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B Generate \u043A\u0430\u043A \u0441\u0435\u0439\u0447\u0430\u0441");
maybeWelcome();
if (openDrawer) {
setChatsPanelOpen(true);
}
if (openDrawer || !force) {
$2("sa_input")?.focus();
}
}
async function switchToChat(id) {
if (!id || id === state.activeChatId) {
@@ -4540,7 +4643,7 @@ ${patch.prompt}`;
syncBuildGenButton();
clearPersistedHistory();
resetContextMemory({ persist: false });
resetMessagesUi('<div class="sa-chat-empty-title">\u0427\u0430\u0442 \u043E\u0447\u0438\u0449\u0435\u043D</div><div class="sa-chat-empty-hint">\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u0441\u0431\u0440\u043E\u0448\u0435\u043D\u044B. \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B Generate \u043D\u0430 \u043C\u0435\u0441\u0442\u0435. <strong>+</strong> \u2014 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442 \u0432 \u0418\u0441\u0442\u043E\u0440\u0438\u044E, <strong>\u0418\u0441\u0442\u043E\u0440\u0438\u044F</strong> \u2014 \u043F\u0440\u043E\u0448\u043B\u044B\u0435 \u0434\u0438\u0430\u043B\u043E\u0433\u0438.</div>');
resetMessagesUi('<div class="sa-chat-empty-title">\u0427\u0430\u0442 \u043E\u0447\u0438\u0449\u0435\u043D</div><div class="sa-chat-empty-hint">\u0421\u043E\u043E\u0431\u0449\u0435\u043D\u0438\u044F \u0441\u0431\u0440\u043E\u0448\u0435\u043D\u044B. \u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B Generate \u043D\u0430 \u043C\u0435\u0441\u0442\u0435. <strong>+</strong> \u2014 \u043D\u043E\u0432\u044B\u0439 \u0447\u0430\u0442, \u043A\u043D\u043E\u043F\u043A\u0430 \u043F\u0430\u043D\u0435\u043B\u0438 \u0441\u043B\u0435\u0432\u0430 \u2014 \u043F\u0440\u043E\u0448\u043B\u044B\u0435 \u0434\u0438\u0430\u043B\u043E\u0433\u0438.</div>');
setStatus("\u0427\u0430\u0442 \u043E\u0447\u0438\u0449\u0435\u043D");
updateSessionLabel();
syncHistoryBadge();
@@ -5577,7 +5680,7 @@ ${patch.prompt}`;
if (!state.gotDelta || !state.busy || state.generating || state.turnSettled) {
return;
}
const waitMs = state.streamFenceDone ? 4e3 : 15e3;
const waitMs = state.streamFenceDone ? 800 : state.gotDelta ? 2200 : 15e3;
if (Date.now() - (state.lastDeltaAt || 0) < waitMs) {
return;
}
@@ -6159,23 +6262,30 @@ ${patch.prompt}`;
state.streamFenceDone = false;
return div;
}
function parseTerminalPatchObject(raw) {
try {
const obj = JSON.parse(String(raw || "").trim());
const terminal = window.SA && typeof SA.isTerminalStreamPatch === "function" ? SA.isTerminalStreamPatch(obj) : typeof isPatchObject === "function" && isPatchObject(obj);
return terminal ? obj : null;
} catch (e) {
return null;
}
}
function streamHasClosedPatchFence(text) {
const t = String(text || "");
if (!/```[\s\S]*```/.test(t)) {
return false;
}
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
while ((match = re.exec(t)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
const terminal = window.SA && typeof SA.isTerminalStreamPatch === "function" ? SA.isTerminalStreamPatch(obj) : typeof isPatchObject === "function" && isPatchObject(obj);
if (terminal) {
if (/```[\s\S]*```/.test(t)) {
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
while ((match = re.exec(t)) !== null) {
if (parseTerminalPatchObject(match[1])) {
return true;
}
} catch (e) {
}
}
const brace = t.lastIndexOf("{");
if (brace >= 0 && parseTerminalPatchObject(t.slice(brace))) {
return true;
}
return false;
}
function trimToClosedPatchFence(text) {
@@ -6184,16 +6294,18 @@ ${patch.prompt}`;
let match;
let lastEnd = -1;
while ((match = re.exec(t)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
const terminal = window.SA && typeof SA.isTerminalStreamPatch === "function" ? SA.isTerminalStreamPatch(obj) : typeof isPatchObject === "function" && isPatchObject(obj);
if (terminal) {
lastEnd = match.index + match[0].length;
}
} catch (e) {
if (parseTerminalPatchObject(match[1])) {
lastEnd = match.index + match[0].length;
}
}
return lastEnd > 0 ? t.slice(0, lastEnd).trimEnd() : t;
if (lastEnd > 0) {
return t.slice(0, lastEnd).trimEnd();
}
const brace = t.lastIndexOf("{");
if (brace >= 0 && parseTerminalPatchObject(t.slice(brace))) {
return t.trimEnd();
}
return t;
}
function appendStreamDelta(delta) {
if (state.streamFenceDone) {
@@ -6218,6 +6330,18 @@ ${patch.prompt}`;
if (streamHasClosedPatchFence(state.streamText)) {
state.streamText = trimToClosedPatchFence(state.streamText);
state.streamFenceDone = true;
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
const fn = state.onClosedTerminalFence;
state.onClosedTerminalFence = null;
if (typeof fn === "function") {
try {
fn(state.streamText);
} catch (e) {
console.warn("Assistent closed-fence finalize", e);
}
}
return;
}
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
@@ -6242,16 +6366,9 @@ ${patch.prompt}`;
const { prose, patch } = extractPatch2(fullReply);
setAssistantBody(el, prose || fullReply || "");
el.querySelectorAll(".sa-patch, .sa-civitai-list").forEach((n) => n.remove());
if (patch && !(card && !patch.prompt && !patch.actions && !patch.loras)) {
if (patch) {
const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
mountPatchBlock(el, patch, { silent });
} else if (card) {
const wrap = document.createElement("div");
wrap.className = "sa-patch sa-card-json-preview";
const pre = document.createElement("pre");
pre.textContent = JSON.stringify(card, null, 2);
wrap.appendChild(pre);
el.appendChild(wrap);
}
if (!(meta && meta.historical)) {
mountCurateButtons(el, meta);
@@ -7387,8 +7504,6 @@ ${data.ui.help_extra}`.trim();
const n = state.inventory.loras.length;
const ck = state.inventory.checkpoints.length;
setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? " (rescanned)" : ""}`);
if (state.view === "cards") {
}
if (done) {
done(state.inventory);
}
@@ -8314,7 +8429,7 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
}
const extracted = typeof extractPatch2 === "function" ? extractPatch2(reply) : { patch: null };
let effective = extracted && extracted.patch ? extracted.patch : null;
if (opts.fromPromptEnRetry && effective && typeof mergePromptEnRewrite === "function") {
if (opts.fromPromptEnRetry && typeof mergePromptEnRewrite === "function" && (effective || state.pendingPromptEnMerge)) {
effective = mergePromptEnRewrite(effective);
}
const S = window.SA && window.SA.session;
@@ -8322,11 +8437,12 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
if (effective && act && typeof act.noteModelCommands === "function") {
act.noteModelCommands(effective);
}
const promptChanged = !!(effective && String(effective.prompt || "").trim());
if (effective && S) {
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
activityDone("delta", {
kind: "delta",
label: "\u041E\u0431\u043D\u043E\u0432\u0438\u043B \u0441\u0435\u0441\u0441\u0438\u044E",
label: promptChanged ? "\u041F\u0440\u043E\u043C\u043F\u0442 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D" : "\u041E\u0431\u043D\u043E\u0432\u0438\u043B \u0441\u0435\u0441\u0441\u0438\u044E",
detail: Object.keys(effective).filter((k) => effective[k] != null && !["actions", "notes"].includes(k)).slice(0, 10).join(", ")
});
try {
@@ -8342,7 +8458,10 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
if (Array.isArray(effective?.actions) && effective.actions.map(String).includes("interrupt")) {
if (typeof doInterruptNow === "function") doInterruptNow();
}
const intent = resolveTurnIntent2(effective, opts.userText || "", opts);
let intent = resolveTurnIntent2(effective, opts.userText || "", opts);
if (opts.userWantsGenerate && effective && !intent.vetoed && !fromAutoCritique) {
intent = { ...intent, generate: true };
}
if (effective) {
if (intent.generate) {
const acts = Array.isArray(effective.actions) ? effective.actions.map(String) : [];
@@ -8450,6 +8569,10 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
if (S && effective) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === "function") syncLiveParamsBar();
if (typeof appendSystemNote === "function") {
appendSystemNote(promptChanged ? "\u041F\u0440\u043E\u043C\u043F\u0442 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \xB7 Generate" : "\u0417\u0430\u043F\u0443\u0441\u043A\u0430\u044E Generate");
}
setStatus(promptChanged ? "\u041F\u0440\u043E\u043C\u043F\u0442 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D \xB7 Generate" : "Generate");
const srcOut = await runGenerateFromPatch(
{ ...effective || {}, actions: ["generate"], generate: true },
{ force: true, fromSession: true }
@@ -8459,12 +8582,20 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
if (typeof maybeAutoCritique === "function") await maybeAutoCritique(srcOut);
if (typeof maybeAutoVisionLook === "function") await maybeAutoVisionLook(srcOut);
}
} else if (effective && $2("sa_auto_apply")?.checked) {
} else if (effective) {
if (S) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === "function") syncLiveParamsBar();
if (promptChanged || $2("sa_auto_apply")?.checked) {
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === "function") syncLiveParamsBar();
if (promptChanged && typeof appendSystemNote === "function") {
appendSystemNote("\u041F\u0440\u043E\u043C\u043F\u0442 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D");
}
if (promptChanged) {
setStatus("\u041F\u0440\u043E\u043C\u043F\u0442 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D");
}
}
if (!state.generating && typeof stopBusyUi === "function") {
stopBusyUi(intent.vetoed ? "\u0417\u0430\u043F\u043E\u043C\u043D\u0438\u043B \xB7 \u0431\u0435\u0437 Generate" : "");
stopBusyUi(intent.vetoed ? "\u0417\u0430\u043F\u043E\u043C\u043D\u0438\u043B \xB7 \u0431\u0435\u0437 Generate" : promptChanged ? "\u041F\u0440\u043E\u043C\u043F\u0442 \u043E\u0431\u043D\u043E\u0432\u043B\u0451\u043D" : "");
}
}
state.pendingSilentGen = false;
@@ -8628,12 +8759,12 @@ ${summary || "(\u043F\u0443\u0441\u0442\u043E)"}`;
return true;
}
if (cmd === "new" || cmd === "newchat") {
await startNewChat({ saveCurrent: true });
await startNewChat({ saveCurrent: true, openDrawer: true });
return true;
}
if (cmd === "history" || cmd === "chats" || cmd === "sessions") {
setChatsPanelOpen(true);
setStatus("/history");
setChatsPanelOpen(!state.chatsPanelOpen);
setStatus(state.chatsPanelOpen ? "/history" : "\u0427\u0430\u0442\u044B \u0441\u043A\u0440\u044B\u0442\u044B");
return true;
}
if (cmd === "compress" || cmd === "compact" || cmd === "\u0441\u0436\u0430\u0442\u044C") {
@@ -9036,6 +9167,8 @@ ${HELP_TEXT}`);
startBusyUi(state.expectColdLoad ? "loading" : "thinking");
}
const context = collectLiveContext();
context.prior_assistant_turns = (state.history || []).filter((m) => m && m.role === "assistant" && !m.systemish).length;
context.do_not_greet = context.prior_assistant_turns > 0;
context.has_vision_image = visionReadySlots().length > 0;
context.images_in_request = !!(images && images.length);
context.attached_slot_ids = attachableSlots().map((s) => s.id);
@@ -9074,6 +9207,7 @@ ${HELP_TEXT}`);
return;
}
state.turnSettled = true;
state.onClosedTerminalFence = null;
clearStreamStall();
if (meta.system_chars != null) {
state.lastSystemChars = Number(meta.system_chars) || 0;
@@ -9128,6 +9262,7 @@ ${HELP_TEXT}`);
return;
}
state.turnSettled = true;
state.onClosedTerminalFence = null;
clearStreamStall();
state.busy = false;
setInterruptVisible(state.generating);
@@ -9144,14 +9279,19 @@ ${HELP_TEXT}`);
};
if (typeof makeWSRequest === "function") {
beginStreamMessage(msgMeta);
armStreamStall(chatEpoch, (reply) => {
const settleStreamReply = (reply) => {
if (state.turnSettled || chatEpoch !== state.chatEpoch) {
return;
}
const raw = String(reply || state.streamText || "").trim() || (state.streamEl?.querySelector(".sa-msg-body")?.textContent || "");
if (state.streamEl) {
finalizeStreamMessage(reply, []);
finalizeStreamMessage(raw, []);
}
finishOk(reply, [], {});
finishOk(raw, [], {});
};
state.onClosedTerminalFence = (text2) => settleStreamReply(text2);
armStreamStall(chatEpoch, (reply) => {
settleStreamReply(reply);
});
makeWSRequest(
"AssistentChatWS",
@@ -9184,12 +9324,13 @@ ${HELP_TEXT}`);
if (data.delta) {
appendStreamDelta(data.delta);
}
if (data.done || data.reply != null) {
if (data.done || typeof data.reply === "string" && data.reply.length > 0 && !data.delta) {
if (state.turnSettled) {
return;
}
const reply = data.reply || state.streamEl?.querySelector(".sa-msg-body")?.textContent || "";
const reply = String(state.streamText || data.reply || "").trim() || state.streamEl?.querySelector(".sa-msg-body")?.textContent || "";
const civitai = data.civitai_results || [];
state.onClosedTerminalFence = null;
if (state.streamEl) {
finalizeStreamMessage(reply, civitai);
}
@@ -9454,7 +9595,14 @@ ${HELP_TEXT}`);
wireSplitter();
registerSendButton();
wireSlashInput();
$2("sa_btn_new_chat")?.addEventListener("click", () => startNewChat({ saveCurrent: true }));
$2("sa_btn_new_chat")?.addEventListener("click", (e) => {
e.stopPropagation();
startNewChat({ saveCurrent: true, openDrawer: true });
});
$2("sa_btn_new_chat_bar")?.addEventListener("click", (e) => {
e.stopPropagation();
startNewChat({ saveCurrent: true, openDrawer: true });
});
$2("sa_ctx_chip")?.addEventListener("click", (e) => {
e.stopPropagation();
toggleCtxPanel();
@@ -9489,16 +9637,6 @@ ${HELP_TEXT}`);
setChatsPanelOpen(!state.chatsPanelOpen);
});
$2("sa_btn_chats_close")?.addEventListener("click", () => setChatsPanelOpen(false));
$2("sa_session_label")?.addEventListener("click", (e) => {
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
});
$2("sa_session_label")?.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setChatsPanelOpen(!state.chatsPanelOpen);
}
});
$2("sa_chats_panel")?.addEventListener("click", (e) => e.stopPropagation());
$2("sa_chats_list")?.addEventListener("click", (e) => {
const row = e.target.closest(".sa-chat-row");
@@ -9513,9 +9651,7 @@ ${HELP_TEXT}`);
}
return;
}
if (e.target.closest("[data-open]")) {
switchToChat(id);
}
switchToChat(id);
});
let chatsSearchTimer = null;
$2("sa_chats_search")?.addEventListener("input", () => {
@@ -9753,7 +9889,11 @@ ${HELP_TEXT}`);
});
$2("sa_chips")?.addEventListener("click", async (e) => {
const btn = e.target.closest(".sa-chip");
if (!btn || state.busy || state.generating) {
if (!btn) {
return;
}
if (state.busy || state.generating) {
setStatus("\u0417\u0430\u043D\u044F\u0442\u043E \u2014 \u043F\u043E\u0434\u043E\u0436\u0434\u0438 \u0438\u043B\u0438 \u043D\u0430\u0436\u043C\u0438 \u0421\u0442\u043E\u043F");
return;
}
const aspect = btn.getAttribute("data-aspect");
+24 -9
View File
@@ -561,9 +561,30 @@
margin-left: 0.15rem;
}
.sa-sessions-toggle {
font-size: 0.78rem !important;
padding: 0.18rem 0.5rem !important;
.sa-chats-toggle {
position: relative;
}
.sa-chats-toggle.sa-sessions-toggle-active,
.sa-drawer-open .sa-chats-toggle {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.sa-chats-count {
position: absolute;
top: -0.2rem;
right: -0.25rem;
min-width: 0.95rem;
height: 0.95rem;
padding: 0 0.22rem;
border-radius: 999px;
background: color-mix(in srgb, #6af 55%, #222);
color: #081018;
font-size: 0.62rem;
font-weight: 700;
line-height: 0.95rem;
text-align: center;
}
.sa-session-label {
@@ -574,16 +595,10 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
border-radius: 0.3rem;
padding: 0.1rem 0.35rem;
}
.sa-session-label:hover {
opacity: 1;
background: color-mix(in srgb, currentColor 10%, transparent);
}
.sa-chats-panel,
.sa-chats-drawer {
/* drawer lives in .sa-chat-workspace — not a dropdown */
+31
View File
@@ -119,6 +119,37 @@ public partial class SwarmAssistentExtension
AddLayer("extra", extraSystem);
}
bool priorAssistant = false;
foreach (JToken msg in userMessages ?? [])
{
if (string.Equals((msg as JObject)?["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase))
{
priorAssistant = true;
break;
}
}
if (!priorAssistant && !string.IsNullOrWhiteSpace(contextJson))
{
try
{
JObject ctx = JObject.Parse(contextJson);
priorAssistant = ctx.Value<bool?>("do_not_greet") == true
|| (ctx["prior_assistant_turns"]?.Value<int?>() ?? 0) > 0;
}
catch
{
// not json
}
}
if (priorAssistant && !slimUtility)
{
AddLayer("continuity",
"Conversation continuity: this thread already has your prior replies. "
+ "Do NOT greet (no «О, привет», no hello, no 👋). "
+ "Do NOT re-introduce yourself or dump your bio/tagline. "
+ "Answer only the latest user message. Name yourself only if asked who you are.");
}
layers["total"] = system.Length;
if (system.Length > 0)
{
+2 -1
View File
@@ -138,7 +138,7 @@ public sealed class AssistentConfig
return
[
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
"actions", "generate", "ask",
"actions", "generate", "ask", "checkpoint",
"use_init_image", "clear_init_image", "init_creativity", "denoise",
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
@@ -1342,6 +1342,7 @@ public sealed class AssistentConfig
sb.AppendLine($"**You are {title}** (the assistant in this chat). The human you talk to is the **user** — a different person.");
sb.AppendLine($"Never address or name the user «{title}» unless `## About the user` explicitly says that is their name.");
sb.AppendLine($"If asked your name («как тебя зовут?» / «who are you?»), answer with **{title}** — do not greet the user by that name instead.");
sb.AppendLine("Do not greet or re-introduce yourself on later turns. If the chat already has your messages, skip hello/bio and answer directly.");
HashSet<string> allow = null;
if (onlyShelves is not null)
+9
View File
@@ -227,6 +227,15 @@ public partial class SwarmAssistentExtension
JObject chunk = JObject.Parse(line);
last = chunk;
string delta = chunk["message"]?["content"]?.ToString() ?? "";
// thinking-only chunks are keep-alives; do not stall, still wait for content/done.
if (string.IsNullOrEmpty(delta))
{
if (chunk["done"]?.Value<bool>() == true)
{
break;
}
continue;
}
if (!string.IsNullOrEmpty(delta))
{
full.Append(delta);
+69 -8
View File
@@ -46,7 +46,7 @@ public partial class SwarmAssistentExtension
patch["look_at"] = patch["vision_slots"];
}
}
if (ActionsContain(patch, "generate"))
if (ActionsContain(patch, "generate") || GenerateFlagOn(patch))
{
patch["generate"] = true;
}
@@ -98,7 +98,28 @@ public partial class SwarmAssistentExtension
// not json
}
}
return lastTerminal ?? lastAny;
if (lastTerminal is not null || lastAny is not null)
{
return lastTerminal ?? lastAny;
}
int brace = reply.LastIndexOf('{');
if (brace < 0)
{
return null;
}
try
{
JObject obj = JObject.Parse(reply.Substring(brace).Trim());
if (obj is not null && Array.Exists(PatchKeys, k => obj[k] is not null))
{
return NormalizePatch(obj);
}
}
catch
{
// not json
}
return null;
}
/// <summary>
@@ -114,10 +135,6 @@ public partial class SwarmAssistentExtension
return false;
}
MatchCollection matches = JsonFenceRe.Matches(reply);
if (matches.Count == 0)
{
return false;
}
for (int i = 0; i < matches.Count; i++)
{
Match match = matches[i];
@@ -137,6 +154,23 @@ public partial class SwarmAssistentExtension
// incomplete / invalid json inside fence
}
}
int brace = reply.LastIndexOf('{');
if (brace >= 0)
{
try
{
JObject obj = NormalizePatch(JObject.Parse(reply.Substring(brace).Trim()));
if (obj is not null && FenceIsTerminalPatch(obj))
{
truncated = reply.TrimEnd();
return true;
}
}
catch
{
// incomplete unfenced json
}
}
return false;
}
@@ -149,7 +183,7 @@ public partial class SwarmAssistentExtension
{
return false;
}
if (obj["generate"]?.Type == JTokenType.Boolean && obj["generate"].Value<bool>())
if (GenerateFlagOn(obj))
{
return true;
}
@@ -176,7 +210,8 @@ public partial class SwarmAssistentExtension
}
if (HasValue(obj, "loras") || HasValue(obj, "aspect") || HasValue(obj, "steps")
|| HasValue(obj, "width") || HasValue(obj, "height") || HasValue(obj, "cfg")
|| HasValue(obj, "seed") || HasValue(obj, "controls"))
|| HasValue(obj, "seed") || HasValue(obj, "controls")
|| HasValue(obj, "checkpoint") || HasValue(obj, "negative"))
{
return true;
}
@@ -220,6 +255,32 @@ public partial class SwarmAssistentExtension
return string.Equals(patch["ask"]?.ToString()?.Trim(), name, StringComparison.OrdinalIgnoreCase);
}
static bool GenerateFlagOn(JObject obj)
{
if (obj is null)
{
return false;
}
if (obj["generate"]?.Type == JTokenType.Boolean && obj["generate"].Value<bool>())
{
return true;
}
if (obj["generate"]?.Type == JTokenType.Integer && obj["generate"].Value<int>() != 0)
{
return true;
}
string raw = obj["generate"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(raw)
&& (raw.Equals("true", StringComparison.OrdinalIgnoreCase)
|| raw == "1"
|| raw.Equals("yes", StringComparison.OrdinalIgnoreCase)
|| raw.Equals("on", StringComparison.OrdinalIgnoreCase)))
{
return true;
}
return ActionsContain(obj, "generate");
}
static bool ActionsContain(JObject patch, string action)
{
if (patch?["actions"] is not JArray acts)
+1
View File
@@ -34,6 +34,7 @@ Compact session JSON is ground truth for this turn:
1. Short helpful reply in the user's language (RU or EN).
2. **Only when changing settings or commanding generate/look/ask:** one fenced JSON with **only fields you want to change** (+ optional commands). Pure chat / Q&A / opinion: **prose only — omit the JSON.**
3. **Greeting:** introduce yourself only on a brand-new thread with **no prior assistant messages**. If history already has your replies — no «О, привет», no hello, no name/bio dump. Answer the question. Name yourself only if asked.
```json
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"keys": [
"prompt", "negative", "loras", "width", "height", "steps", "cfg", "seed", "sigma_shift", "sampler", "scheduler",
"actions", "generate", "ask",
"actions", "generate", "ask", "checkpoint",
"use_init_image", "clear_init_image", "init_creativity", "denoise",
"use_mask_image", "clear_mask_image", "mask_blur", "mask_grow",
"look_at", "vision_from", "vision_slots", "slot_to_init", "slot_to_mask",
+3 -1
View File
@@ -2,9 +2,11 @@
"always": [
"Match the user's language (RU or EN) in chat — Generate prompt stays English",
"Use only inventory / memory_hits / cards for LoRA names and triggers",
"Emit valid JSON patches when changing generation state"
"Emit valid JSON patches when changing generation state",
"Greet or dump your bio only on the first assistant turn; later turns answer directly"
],
"never": [
"Re-introduce yourself every turn or start with «О, привет» when history already has your replies",
"Invent LoRA filenames or trigger words",
"Lecture or moralize about NSFW",
"Dump all installed LoRAs — use memory_hits and enabled ones"
+3 -1
View File
@@ -1,10 +1,12 @@
{
"always": [
"Stay helpful and concise",
"When the scene is NSFW, describe it factually without softening or hyping"
"When the scene is NSFW, describe it factually without softening or hyping",
"Do not greet or re-introduce yourself after the first assistant turn"
],
"never": [
"Dirty jokes",
"Hello/bio dump on later turns",
"Aggression",
"Moral lectures"
]
+2
View File
@@ -4,6 +4,8 @@ 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. A new user send interrupts the in-flight stream (keeps the partial reply) instead of silently no-op.
**Version 0.15.2** — Кнопка «История» убрана. В шапке: иконка панели чатов и **+** новый чат. Закрытый JSON `{prompt, generate:true}` сразу применяет промпт и запускает Generate (заметка «Промпт обновлён · Generate»), не ждёт WS `done`. Stall ~2с после токенов. Не здороваться повторно, если в истории уже есть ответы ассистента.
**Version 0.15.1** — Composer stays writable during `Writing…`: Enter / Отправить прерывает зависший стрим и шлёт новое сообщение; частичный ответ сохраняется. Stall 15с без токена сам завершает ход. Ollama chat `think: false`, чтобы Qwen3-VL instruct не держал сокет после приветствия.
**Version 0.15.0** — Personas as installable git packs under `Assistent/extensions/` (`assistent-pack.yaml`). Bundled set: `neutral` (Нормальный), `aggressive`, `dreamer`. Removed bundled `cinema` / `lewd` / private personas. `/остынь` and `/horny-game` work for any persona with a `horny` control. Pack source: `pack` / `overlay+pack`.
+1 -1
View File
@@ -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.15.1";
Version = "0.15.2";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"];
}
+10 -4
View File
@@ -55,8 +55,14 @@
<header class="sa-chat-header">
<div class="sa-chat-title">
<div class="sa-sessions-bar">
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
<span class="sa-session-label" id="sa_session_label" title="Текущий чат" role="button" tabindex="0">Новый чат</span>
<button type="button" class="sa-chats-icon-btn sa-chats-toggle" id="sa_btn_chats" title="Показать чаты" aria-expanded="false" aria-controls="sa_chats_panel" aria-label="Показать чаты">
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden="true"><rect x="2.2" y="3" width="11.6" height="10" rx="1.4" stroke="currentColor" stroke-width="1.4"/><path d="M10.2 3v10" stroke="currentColor" stroke-width="1.4"/></svg>
<span class="sa-chats-count" id="sa_chats_count" hidden></span>
</button>
<span class="sa-session-label" id="sa_session_label" title="Текущий чат">Новый чат</span>
<button type="button" class="sa-chats-icon-btn" id="sa_btn_new_chat_bar" title="Новый чат" aria-label="Новый чат">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
</div>
<div class="sa-header-right">
@@ -132,14 +138,14 @@
</div>
</div>
</section>
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="История чатов">
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="Чаты">
<div class="sa-chats-drawer-head">
<span class="sa-chats-drawer-label">Чаты</span>
<div class="sa-chats-drawer-actions">
<button type="button" class="sa-chats-icon-btn" id="sa_btn_new_chat" title="Новый чат" aria-label="Новый чат">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
<button type="button" class="sa-chats-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">
<button type="button" class="sa-chats-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть чаты">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
</div>
+196 -95
View File
@@ -79,8 +79,8 @@
let HELP_TEXT = `Slash-команды (без LLM):
/help этот список
/new новый чат (текущий сохранится в Историю)
/history открыть список чатов
/new новый чат (текущий сохранится в списке)
/history открыть или скрыть панель чатов
/compress сжать старые ходы в саммари
/debug сводка UI/Exact (без LLM)
/debug ask то же + короткий ответ модели
@@ -96,12 +96,12 @@
/inventory rescan моделей + обновить список LoRA
Чипсы над полем ввода делают то же для aspect / seed / vary / Turbo·RAW.
При старте всегда новый чат; смена чата в Истории восстанавливает параметры.`;
При старте всегда новый чат; смена чата в панели восстанавливает параметры.`;
let SLASH_COMMANDS = [
{ cmd: '/help', hint: 'список команд' },
{ cmd: '/new', hint: 'новый чат' },
{ cmd: '/history', hint: 'история чатов' },
{ cmd: '/history', hint: 'панель чатов' },
{ cmd: '/compress', hint: 'сжать старые ходы' },
{ cmd: '/debug', hint: 'сводка · ask = с LLM' },
{ cmd: '/why', hint: 'debug + пояснение LLM' },
@@ -234,7 +234,7 @@
// ---- Turn lifecycle --------------------------------------------------
// A turn is one user utterance. It can fan out into nested LLM hops: Krea
// prompt prep, empty-patch retry, vision, auto-critique. They share one
// prompt prep, ask:settings/inventory, vision, auto-critique. They share one
// budget so a turn always terminates, and the text they carry is written by
// the client, not the user — intent heuristics must never read it.
@@ -1091,9 +1091,18 @@
function mergePromptEnRewrite(effective) {
const base = state.pendingPromptEnMerge;
state.pendingPromptEnMerge = null;
if (!base || !effective) {
if (!base) {
return effective;
}
if (!effective) {
return {
...base,
generate: true,
actions: (Array.isArray(base.actions) && base.actions.length)
? base.actions
: ['generate'],
};
}
return {
...base,
...effective,
@@ -1141,6 +1150,22 @@
}
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
}
function userAsksGenerate(text) {
if (window.SA && typeof SA.userAsksGenerate === 'function') {
return SA.userAsksGenerate(text);
}
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t)) {
return false;
}
if (/\b(generat(e|ion)|draw|render|make\s+(an?\s+)?image|run\s+generate)\b/i.test(t)) {
return true;
}
return cyrTokenRe(
'сгенер[а-яё]*|нарисуй|нарисуйте|'
+ 'запусти\\s+генер[а-яё]*|сделай\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
).test(t);
}
function packWantsVision(pack) {
const p = String(pack || '');
@@ -1149,12 +1174,20 @@
function resolveTurnIntent(patch, userText, opts = {}) {
const S = window.SA && window.SA.session;
if (S && typeof S.resolveTurnIntent === 'function') {
return S.resolveTurnIntent(patch, userText, { vetoFn: userAsksNoGenerate });
return S.resolveTurnIntent(patch, userText, {
vetoFn: userAsksNoGenerate,
askGenerateFn: userAsksGenerate,
fromAutoCritique: !!opts.fromAutoCritique,
});
}
// generate:true / actions / user «сгенерируй» when a prompt already exists.
const vetoed = !isMachineTurn(opts) && userAsksNoGenerate(userText);
const modelAsked = !!(patch && (patch.generate === true
const modelAsked = !!(patch && (patch.generate === true || patch.generate === 1
|| (typeof patch.generate === 'string' && /^(true|1|yes|on)$/i.test(patch.generate))
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'))));
const generate = !vetoed && !opts.fromAutoCritique && modelAsked;
const userAsked = !isMachineTurn(opts) && userAsksGenerate(userText)
&& !!(modelAsked || String(patch?.prompt || '').trim());
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked);
const hasLook = !!patch
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const look = !!(hasLook && !vetoed && !generate);
@@ -2435,6 +2468,7 @@
genBtn.textContent = 'Сгенерировать';
genBtn.addEventListener('click', async () => {
if (isGenerateUnavailable()) {
setStatus('Generate недоступен — подожди или нажми Стоп');
return;
}
startBusyUi('silent_gen');
@@ -3294,7 +3328,7 @@
empty.className = 'sa-chat-empty';
empty.id = 'sa_chat_empty';
empty.innerHTML = emptyHint
|| '<div class="sa-chat-empty-title">Новый чат</div><div class="sa-chat-empty-hint">Параметры Generate остаются как сейчас.<br><strong>+</strong> — ещё один чат · <strong>История</strong> — вернуться к прошлому (с его параметрами).</div>';
|| '<div class="sa-chat-empty-title">Новый чат</div><div class="sa-chat-empty-hint">Параметры Generate остаются как сейчас.<br><strong>+</strong> — ещё один чат · кнопка панели слева — прошлые диалоги.</div>';
box.appendChild(empty);
}
@@ -3357,23 +3391,40 @@
}
const chat = findChat(state.activeChatId);
el.textContent = chat?.title || 'Новый чат';
el.title = (chat?.title || 'Новый чат') + ' — клик: История';
el.title = chat?.title || 'Новый чат';
}
function chatHasTranscript(c) {
return ((c?.messages || []).length > 0) || ((c?.messages_count || 0) > 0);
}
function savedChatsCount() {
return (state.chats || []).filter((c) => (c.messages || []).length > 0).length;
return (state.chats || []).filter(chatHasTranscript).length;
}
function isBlankActiveChat() {
if ((state.history || []).length) {
return false;
}
const chat = findChat(state.activeChatId);
return !chat || !chatHasTranscript(chat);
}
function syncHistoryBadge() {
const btn = $('sa_btn_chats');
if (!btn) {
return;
const countEl = $('sa_chats_count');
const n = (state.chats || []).filter(chatHasTranscript).length;
const open = !!state.chatsPanelOpen;
if (btn) {
btn.title = open
? (n ? `Скрыть чаты (${n})` : 'Скрыть чаты')
: (n ? `Показать чаты (${n})` : 'Показать чаты');
btn.setAttribute('aria-label', btn.title);
}
if (countEl) {
countEl.hidden = n < 1;
countEl.textContent = n > 99 ? '99+' : String(n);
}
const n = savedChatsCount();
btn.textContent = n > 0 ? `История (${n})` : 'История';
btn.title = n > 0
? `Сохранённых чатов: ${n}. Переключение восстанавливает параметры.`
: 'История чатов (пока пусто)';
}
function formatChatWhen(ts) {
@@ -3450,7 +3501,7 @@
let chats = (state.chats || [])
.slice()
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
.filter((c) => (c.messages || []).length > 0 || (c.messages_count || 0) > 0);
.filter((c) => c.id === state.activeChatId || chatHasTranscript(c));
if (q) {
const local = chats.filter((c) => chatMatchesQuery(c, q));
const seen = new Set(local.map((c) => c.id));
@@ -3491,6 +3542,7 @@
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');
syncHistoryBadge();
if (state.chatsPanelOpen) {
saveActiveChatToStore();
const search = $('sa_chats_search');
@@ -3503,14 +3555,17 @@
saveUiStateToDisk();
}
async function startNewChat({ saveCurrent = true, force = false } = {}) {
if (!force && (state.busy || state.generating)) {
setStatus('Занято — дождись конца ответа или Стоп');
async function startNewChat({ saveCurrent = true, force = false, openDrawer = false } = {}) {
if (!force && isBlankActiveChat()) {
setStatus('Уже новый чат');
if (openDrawer) {
setChatsPanelOpen(true);
}
$('sa_input')?.focus();
return;
}
if (force) {
// Drop in-flight reply so it cannot land in the new chat.
abortInFlightWork({ status: '' });
if (state.busy || state.generating) {
abortInFlightWork({ status: '', interruptSwarm: !!state.generating });
}
if (saveCurrent) {
saveActiveChatToStore({ dropEmpty: true });
@@ -3549,6 +3604,12 @@
updateCtxChip();
setStatus('Новый чат — параметры Generate как сейчас');
maybeWelcome();
if (openDrawer) {
setChatsPanelOpen(true);
}
if (openDrawer || !force) {
$('sa_input')?.focus();
}
}
async function switchToChat(id) {
@@ -3692,7 +3753,7 @@
syncBuildGenButton();
clearPersistedHistory();
resetContextMemory({ persist: false });
resetMessagesUi('<div class="sa-chat-empty-title">Чат очищен</div><div class="sa-chat-empty-hint">Сообщения сброшены. Параметры Generate на месте. <strong>+</strong> — новый чат в Историю, <strong>История</strong> — прошлые диалоги.</div>');
resetMessagesUi('<div class="sa-chat-empty-title">Чат очищен</div><div class="sa-chat-empty-hint">Сообщения сброшены. Параметры Generate на месте. <strong>+</strong> — новый чат, кнопка панели слева — прошлые диалоги.</div>');
setStatus('Чат очищен');
updateSessionLabel();
syncHistoryBadge();
@@ -4841,7 +4902,7 @@
if (!state.gotDelta || !state.busy || state.generating || state.turnSettled) {
return;
}
const waitMs = state.streamFenceDone ? 4000 : 15000;
const waitMs = state.streamFenceDone ? 800 : (state.gotDelta ? 2200 : 15000);
if (Date.now() - (state.lastDeltaAt || 0) < waitMs) {
return;
}
@@ -5477,23 +5538,32 @@ if (role === 'assistant' && !(meta && meta.historical)) {
return div;
}
function parseTerminalPatchObject(raw) {
try {
const obj = JSON.parse(String(raw || '').trim());
const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
? SA.isTerminalStreamPatch(obj)
: (typeof isPatchObject === 'function' && isPatchObject(obj));
return terminal ? obj : null;
} catch (e) {
return null;
}
}
function streamHasClosedPatchFence(text) {
const t = String(text || '');
if (!/```[\s\S]*```/.test(t)) {
return false;
}
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
while ((match = re.exec(t)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
? SA.isTerminalStreamPatch(obj)
: (typeof isPatchObject === 'function' && isPatchObject(obj));
if (terminal) {
if (/```[\s\S]*```/.test(t)) {
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
while ((match = re.exec(t)) !== null) {
if (parseTerminalPatchObject(match[1])) {
return true;
}
} catch (e) { /* ignore */ }
}
}
const brace = t.lastIndexOf('{');
if (brace >= 0 && parseTerminalPatchObject(t.slice(brace))) {
return true;
}
return false;
}
@@ -5504,17 +5574,18 @@ if (role === 'assistant' && !(meta && meta.historical)) {
let match;
let lastEnd = -1;
while ((match = re.exec(t)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
const terminal = (window.SA && typeof SA.isTerminalStreamPatch === 'function')
? SA.isTerminalStreamPatch(obj)
: (typeof isPatchObject === 'function' && isPatchObject(obj));
if (terminal) {
lastEnd = match.index + match[0].length;
}
} catch (e) { /* ignore */ }
if (parseTerminalPatchObject(match[1])) {
lastEnd = match.index + match[0].length;
}
}
return lastEnd > 0 ? t.slice(0, lastEnd).trimEnd() : t;
if (lastEnd > 0) {
return t.slice(0, lastEnd).trimEnd();
}
const brace = t.lastIndexOf('{');
if (brace >= 0 && parseTerminalPatchObject(t.slice(brace))) {
return t.trimEnd();
}
return t;
}
function appendStreamDelta(delta) {
@@ -5540,6 +5611,18 @@ if (role === 'assistant' && !(meta && meta.historical)) {
if (streamHasClosedPatchFence(state.streamText)) {
state.streamText = trimToClosedPatchFence(state.streamText);
state.streamFenceDone = true;
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
const fn = state.onClosedTerminalFence;
state.onClosedTerminalFence = null;
if (typeof fn === 'function') {
try {
fn(state.streamText);
} catch (e) {
console.warn('Assistent closed-fence finalize', e);
}
}
return;
}
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
@@ -5562,19 +5645,12 @@ if (role === 'assistant' && !(meta && meta.historical)) {
}
el.classList.remove('sa-streaming', 'sa-typing');
mountAssistantMeta(el, meta || undefined);
const { prose, patch } = extractPatch(fullReply);
const { prose, patch } = extractPatch(fullReply);
setAssistantBody(el, prose || fullReply || '');
el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove());
if (patch && !(card && !patch.prompt && !patch.actions && !patch.loras)) {
if (patch) {
const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
mountPatchBlock(el, patch, { silent });
} else if (card) {
const wrap = document.createElement('div');
wrap.className = 'sa-patch sa-card-json-preview';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(card, null, 2);
wrap.appendChild(pre);
el.appendChild(wrap);
}
if (!(meta && meta.historical)) {
mountCurateButtons(el, meta);
@@ -6754,10 +6830,6 @@ if (!(meta && meta.historical)) {
const n = state.inventory.loras.length;
const ck = state.inventory.checkpoints.length;
setStatus(`Inventory: ${n} LoRAs, ${ck} ckpts${rescan ? ' (rescanned)' : ''}`);
if (state.view === 'cards') {
}
if (done) {
done(state.inventory);
}
@@ -7731,7 +7803,8 @@ if (!(meta && meta.historical)) {
}
const extracted = typeof extractPatch === 'function' ? extractPatch(reply) : { patch: null };
let effective = extracted && extracted.patch ? extracted.patch : null;
if (opts.fromPromptEnRetry && effective && typeof mergePromptEnRewrite === 'function') {
if (opts.fromPromptEnRetry && typeof mergePromptEnRewrite === 'function'
&& (effective || state.pendingPromptEnMerge)) {
effective = mergePromptEnRewrite(effective);
}
const S = window.SA && window.SA.session;
@@ -7739,11 +7812,12 @@ if (!(meta && meta.historical)) {
if (effective && act && typeof act.noteModelCommands === 'function') {
act.noteModelCommands(effective);
}
const promptChanged = !!(effective && String(effective.prompt || '').trim());
if (effective && S) {
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
activityDone('delta', {
kind: 'delta',
label: 'Обновил сессию',
label: promptChanged ? 'Промпт обновлён' : 'Обновил сессию',
detail: Object.keys(effective).filter((k) => effective[k] != null
&& !['actions', 'notes'].includes(k)).slice(0, 10).join(', '),
});
@@ -7759,7 +7833,10 @@ if (!(meta && meta.historical)) {
if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
if (typeof doInterruptNow === 'function') doInterruptNow();
}
const intent = resolveTurnIntent(effective, opts.userText || '', opts);
let intent = resolveTurnIntent(effective, opts.userText || '', opts);
if (opts.userWantsGenerate && effective && !intent.vetoed && !fromAutoCritique) {
intent = { ...intent, generate: true };
}
if (effective) {
if (intent.generate) {
const acts = Array.isArray(effective.actions) ? effective.actions.map(String) : [];
@@ -7835,7 +7912,8 @@ if (!(meta && meta.historical)) {
}
if (intent.generate && effective?.prompt
&& typeof promptNeedsKreaPrep === 'function' && promptNeedsKreaPrep(effective.prompt)
&& !fromVisionHop && typeof claimTurnHop === 'function' && claimTurnHop('krea_prep')) {
&& !fromVisionHop
&& typeof claimTurnHop === 'function' && claimTurnHop('krea_prep')) {
state.pendingPromptEnMerge = { ...effective };
activityStep('prep', {
kind: 'prep',
@@ -7870,6 +7948,10 @@ if (!(meta && meta.historical)) {
if (S && effective) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === 'function') syncLiveParamsBar();
if (typeof appendSystemNote === 'function') {
appendSystemNote(promptChanged ? 'Промпт обновлён · Generate' : 'Запускаю Generate');
}
setStatus(promptChanged ? 'Промпт обновлён · Generate' : 'Generate');
const srcOut = await runGenerateFromPatch(
{ ...(effective || {}), actions: ['generate'], generate: true },
{ force: true, fromSession: true },
@@ -7879,12 +7961,20 @@ if (!(meta && meta.historical)) {
if (typeof maybeAutoCritique === 'function') await maybeAutoCritique(srcOut);
if (typeof maybeAutoVisionLook === 'function') await maybeAutoVisionLook(srcOut);
}
} else if (effective && $('sa_auto_apply')?.checked) {
} else if (effective) {
if (S) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === 'function') syncLiveParamsBar();
if (promptChanged || $('sa_auto_apply')?.checked) {
await pushSessionToSwarm(state.chatSession);
if (typeof syncLiveParamsBar === 'function') syncLiveParamsBar();
if (promptChanged && typeof appendSystemNote === 'function') {
appendSystemNote('Промпт обновлён');
}
if (promptChanged) {
setStatus('Промпт обновлён');
}
}
if (!state.generating && typeof stopBusyUi === 'function') {
stopBusyUi(intent.vetoed ? 'Запомнил · без Generate' : '');
stopBusyUi(intent.vetoed ? 'Запомнил · без Generate' : (promptChanged ? 'Промпт обновлён' : ''));
}
}
state.pendingSilentGen = false;
@@ -8058,12 +8148,12 @@ if (!(meta && meta.historical)) {
return true;
}
if (cmd === 'new' || cmd === 'newchat') {
await startNewChat({ saveCurrent: true });
await startNewChat({ saveCurrent: true, openDrawer: true });
return true;
}
if (cmd === 'history' || cmd === 'chats' || cmd === 'sessions') {
setChatsPanelOpen(true);
setStatus('/history');
setChatsPanelOpen(!state.chatsPanelOpen);
setStatus(state.chatsPanelOpen ? '/history' : 'Чаты скрыты');
return true;
}
if (cmd === 'compress' || cmd === 'compact' || cmd === 'сжать') {
@@ -8501,6 +8591,8 @@ if (!(meta && meta.historical)) {
const context = collectLiveContext();
// has_vision_image = board has a real frame (even when JPEG is not in this request).
// images_in_request = JPEG bytes are attached to the last user message this turn.
context.prior_assistant_turns = (state.history || []).filter((m) => m && m.role === 'assistant' && !m.systemish).length;
context.do_not_greet = context.prior_assistant_turns > 0;
context.has_vision_image = visionReadySlots().length > 0;
context.images_in_request = !!(images && images.length);
context.attached_slot_ids = attachableSlots().map((s) => s.id);
@@ -8544,6 +8636,7 @@ if (!(meta && meta.historical)) {
return;
}
state.turnSettled = true;
state.onClosedTerminalFence = null;
clearStreamStall();
if (meta.system_chars != null) {
state.lastSystemChars = Number(meta.system_chars) || 0;
@@ -8601,6 +8694,7 @@ if (!(meta && meta.historical)) {
return;
}
state.turnSettled = true;
state.onClosedTerminalFence = null;
clearStreamStall();
state.busy = false;
setInterruptVisible(state.generating);
@@ -8618,14 +8712,20 @@ if (!(meta && meta.historical)) {
if (typeof makeWSRequest === 'function') {
beginStreamMessage(msgMeta);
armStreamStall(chatEpoch, (reply) => {
const settleStreamReply = (reply) => {
if (state.turnSettled || chatEpoch !== state.chatEpoch) {
return;
}
const raw = String(reply || state.streamText || '').trim()
|| (state.streamEl?.querySelector('.sa-msg-body')?.textContent || '');
if (state.streamEl) {
finalizeStreamMessage(reply, []);
finalizeStreamMessage(raw, []);
}
finishOk(reply, [], {});
finishOk(raw, [], {});
};
state.onClosedTerminalFence = (text) => settleStreamReply(text);
armStreamStall(chatEpoch, (reply) => {
settleStreamReply(reply);
});
makeWSRequest(
'AssistentChatWS',
@@ -8661,12 +8761,14 @@ if (!(meta && meta.historical)) {
if (data.delta) {
appendStreamDelta(data.delta);
}
if (data.done || data.reply != null) {
if (data.done || (typeof data.reply === 'string' && data.reply.length > 0 && !data.delta)) {
if (state.turnSettled) {
return;
}
const reply = data.reply || (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || '';
const reply = String(state.streamText || data.reply || '').trim()
|| (state.streamEl?.querySelector('.sa-msg-body')?.textContent) || '';
const civitai = data.civitai_results || [];
state.onClosedTerminalFence = null;
if (state.streamEl) {
finalizeStreamMessage(reply, civitai);
}
@@ -8944,7 +9046,14 @@ if (!(meta && meta.historical)) {
wireSlashInput();
$('sa_btn_new_chat')?.addEventListener('click', () => startNewChat({ saveCurrent: true }));
$('sa_btn_new_chat')?.addEventListener('click', (e) => {
e.stopPropagation();
startNewChat({ saveCurrent: true, openDrawer: true });
});
$('sa_btn_new_chat_bar')?.addEventListener('click', (e) => {
e.stopPropagation();
startNewChat({ saveCurrent: true, openDrawer: true });
});
$('sa_ctx_chip')?.addEventListener('click', (e) => {
e.stopPropagation();
toggleCtxPanel();
@@ -8979,16 +9088,6 @@ if (!(meta && meta.historical)) {
setChatsPanelOpen(!state.chatsPanelOpen);
});
$('sa_btn_chats_close')?.addEventListener('click', () => setChatsPanelOpen(false));
$('sa_session_label')?.addEventListener('click', (e) => {
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
});
$('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) => {
const row = e.target.closest('.sa-chat-row');
@@ -9003,9 +9102,7 @@ if (!(meta && meta.historical)) {
}
return;
}
if (e.target.closest('[data-open]')) {
switchToChat(id);
}
switchToChat(id);
});
let chatsSearchTimer = null;
$('sa_chats_search')?.addEventListener('input', () => {
@@ -9248,7 +9345,11 @@ if (!(meta && meta.historical)) {
});
$('sa_chips')?.addEventListener('click', async (e) => {
const btn = e.target.closest('.sa-chip');
if (!btn || state.busy || state.generating) {
if (!btn) {
return;
}
if (state.busy || state.generating) {
setStatus('Занято — подожди или нажми Стоп');
return;
}
const aspect = btn.getAttribute('data-aspect');
+34 -5
View File
@@ -1,4 +1,4 @@
/** Turn intent — veto only; generate comes from model `generate: true` (or legacy actions). */
/** Turn intent — veto + model generate; user «сгенерируй» also counts when a prompt/delta exists. */
export function cyrTokenRe(alts) {
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
@@ -23,6 +23,20 @@ export function userAsksNoGenerate(text) {
).test(t);
}
export function userAsksGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t)) {
return false;
}
if (/\b(generat(e|ion)|draw|render|make\s+(an?\s+)?image|run\s+generate)\b/i.test(t)) {
return true;
}
return cyrTokenRe(
'сгенер[а-яё]*|нарисуй|нарисуйте|'
+ 'запусти\\s+генер[а-яё]*|сделай\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
).test(t);
}
export function userAsksLook(text) {
const t = String(text || '').trim();
if (!t) {
@@ -56,12 +70,27 @@ export function packWantsVision(pack) {
return p === 'critique_image' || p === 'describe_ref' || p === 'compose_scene' || p === 'inpaint_edit';
}
/** Model generate + explicit veto. No RU imply/command heuristics. */
function generateFlagOn(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
const g = patch.generate;
if (g === true || g === 1) {
return true;
}
if (typeof g === 'string' && /^(true|1|yes|on)$/i.test(g.trim())) {
return true;
}
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
}
/** Model generate flag, or user «сгенерируй» when a prompt/delta already exists. */
export function resolveTurnIntent(patch, userText, opts = {}) {
const vetoed = !opts.machineTurn && userAsksNoGenerate(userText);
const modelAsked = patch?.generate === true
|| (Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate'));
const generate = !vetoed && !opts.fromAutoCritique && !!modelAsked;
const modelAsked = generateFlagOn(patch);
const userAsked = !opts.machineTurn && userAsksGenerate(userText)
&& !!(modelAsked || String(patch?.prompt || '').trim());
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked);
const hasLook = !!patch
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const look = !!(hasLook && !vetoed && !generate);
+66 -18
View File
@@ -2,7 +2,7 @@
const DEFAULT_PATCH_KEYS = [
'prompt', 'negative', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', 'scheduler',
'actions', 'generate', 'ask',
'actions', 'generate', 'ask', 'checkpoint',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
@@ -30,6 +30,22 @@ function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null;
}
/** true / "true" / 1 / actions:["generate"] — models sometimes stringify the flag. */
export function generateFlagOn(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const g = obj.generate;
if (g === true || g === 1) {
return true;
}
if (typeof g === 'string' && /^(true|1|yes|on)$/i.test(g.trim())) {
return true;
}
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
return acts.includes('generate');
}
export function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
@@ -42,7 +58,7 @@ export function normalizePatch(patch) {
return patch;
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
if (patch.generate === true || acts.includes('generate')) {
if (generateFlagOn(patch) || acts.includes('generate')) {
patch.generate = true;
}
if (typeof patch.ask === 'string') {
@@ -61,31 +77,65 @@ export function normalizePatch(patch) {
return patch;
}
function tryParsePatchJson(raw) {
try {
const obj = JSON.parse(String(raw || '').trim());
if (isPatchObject(obj)) {
return normalizePatch(obj);
}
} catch (e) { /* not json */ }
return null;
}
export function extractPatch(text) {
if (!text) {
return { prose: text || '', patch: null };
}
const re = new RegExp(FENCE_RE.source, 'gi');
let match;
let lastPatch = null;
let prose = text;
let lastAny = null;
let lastAnyIndex = -1;
let lastAnyLen = 0;
let lastTerminal = null;
let lastTermIndex = -1;
let lastTermLen = 0;
while ((match = re.exec(text)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj)) {
lastPatch = normalizePatch(obj);
prose = (text.slice(0, match.index) + text.slice(match.index + match[0].length)).trim();
}
} catch (e) { /* not json */ }
const parsed = tryParsePatchJson(match[1]);
if (!parsed) {
continue;
}
lastAny = parsed;
lastAnyIndex = match.index;
lastAnyLen = match[0].length;
if (isTerminalStreamPatch(parsed)) {
lastTerminal = parsed;
lastTermIndex = match.index;
lastTermLen = match[0].length;
}
}
return { prose, patch: lastPatch };
const chosen = lastTerminal || lastAny;
if (chosen) {
const idx = lastTerminal ? lastTermIndex : lastAnyIndex;
const len = lastTerminal ? lastTermLen : lastAnyLen;
const prose = (text.slice(0, idx) + text.slice(idx + len)).trim();
return { prose, patch: chosen };
}
// Unfenced trailing object — some turns emit raw {prompt, generate:true}.
const brace = text.lastIndexOf('{');
if (brace >= 0) {
const parsed = tryParsePatchJson(text.slice(brace));
if (parsed) {
return { prose: text.slice(0, brace).trim(), patch: parsed };
}
}
return { prose: text, patch: null };
}
export function isTerminalStreamPatch(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
if (obj.generate === true) {
if (generateFlagOn(obj)) {
return true;
}
if (Array.isArray(obj.ask) && obj.ask.length) {
@@ -100,16 +150,13 @@ export function isTerminalStreamPatch(obj) {
if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
return true;
}
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
if (acts.includes('generate')) {
return true;
}
if (String(obj.prompt || '').trim().length >= 48) {
return true;
}
if (obj.loras != null || obj.aspect != null || obj.steps != null
|| obj.width != null || obj.height != null || obj.cfg != null
|| obj.seed != null || obj.controls != null) {
|| obj.seed != null || obj.controls != null || obj.checkpoint != null
|| obj.negative != null) {
return true;
}
return false;
@@ -122,4 +169,5 @@ export function attachPatch(SA) {
SA.isTerminalStreamPatch = isTerminalStreamPatch;
SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch;
SA.generateFlagOn = generateFlagOn;
}
+27 -18
View File
@@ -59,7 +59,11 @@ export function normalizeDelta(raw) {
}
const delta = { ...raw };
const acts = Array.isArray(delta.actions) ? delta.actions.map(String) : [];
if (delta.generate === true || acts.includes('generate')) {
const g = delta.generate;
const generateOn = g === true || g === 1
|| (typeof g === 'string' && /^(true|1|yes|on)$/i.test(g.trim()))
|| acts.includes('generate');
if (generateOn) {
delta.generate = true;
}
if (typeof delta.ask === 'string') {
@@ -77,7 +81,8 @@ export function patchWantsGenerate(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
if (patch.generate === true) {
const n = normalizeDelta(patch);
if (n?.generate === true) {
return true;
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
@@ -340,10 +345,14 @@ export function fullSettingsDump(session, extras = {}) {
};
}
export function resolveTurnIntent(patch, userText, { vetoFn } = {}) {
/** Model generate/actions, or askGenerateFn when a prompt/delta exists. vetoFn / fromAutoCritique cancel. */
export function resolveTurnIntent(patch, userText, { vetoFn, askGenerateFn, fromAutoCritique } = {}) {
const delta = normalizeDelta(patch) || {};
const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false;
const generate = !vetoed && patchWantsGenerate(delta);
const modelAsked = patchWantsGenerate(delta);
const userAsked = typeof askGenerateFn === 'function' && !!askGenerateFn(userText)
&& !!(modelAsked || String(delta.prompt || '').trim());
const generate = !vetoed && !fromAutoCritique && (modelAsked || userAsked);
const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null;
const look = !!(hasLook && !generate && !vetoed);
const ask = patchAskList(delta);
@@ -390,20 +399,20 @@ export function mergeExactParamsForGenerate(patch, {
const defaults = resolveExactProfileDefaults({ exact, profiles, profileName });
const out = { ...patch };
const clearSessionKeys = [];
for (const key of EXACT_GENERATE_PARAM_KEYS) {
if (out[key] != null) {
// Sparse LLM may echo live leftovers (20/7). Without user intent, force Exact.
if (
!userParamIntent
&& defaults[key] != null
&& String(out[key]) !== String(defaults[key])
) {
out[key] = defaults[key];
if (sessionExact?.[key] != null && String(sessionExact[key]) !== String(defaults[key])) {
clearSessionKeys.push(key);
}
}
continue;
for (const key of EXACT_GENERATE_PARAM_KEYS) {
if (out[key] != null) {
// Sparse LLM may echo live leftovers (20/7). Without user intent, force Exact.
if (
!userParamIntent
&& defaults[key] != null
&& String(out[key]) !== String(defaults[key])
) {
out[key] = defaults[key];
if (sessionExact?.[key] != null && String(sessionExact[key]) !== String(defaults[key])) {
clearSessionKeys.push(key);
}
}
continue;
}
if (userParamIntent && sessionExact?.[key] != null) {
out[key] = sessionExact[key];
+14 -3
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
userAsksLook,
userAsksNoGenerate,
userAsksGenerate,
resolveTurnIntent,
packWantsVision,
} from '../src/intent.js';
@@ -19,7 +20,14 @@ describe('intent.js', () => {
assert.equal(userAsksNoGenerate('нарисуй лису'), false);
});
it('resolveTurnIntent uses model generate only + veto', () => {
it('userAsksGenerate detects сгенерируй', () => {
assert.equal(userAsksGenerate('сгенерируй'), true);
assert.equal(userAsksGenerate('нарисуй лису'), true);
assert.equal(userAsksGenerate('что ты умеешь'), false);
assert.equal(userAsksGenerate('не генерируй'), false);
});
it('resolveTurnIntent uses model generate + user generate ask + veto', () => {
const lookOnly = resolveTurnIntent({ look_at: ['generate'] }, 'что не так с кадром?', {});
assert.equal(lookOnly.generate, false);
assert.equal(lookOnly.look, true);
@@ -27,12 +35,15 @@ describe('intent.js', () => {
const gen = resolveTurnIntent({ prompt: 'fox', generate: true }, 'нарисуй лису', {});
assert.equal(gen.generate, true);
const userGen = resolveTurnIntent({ prompt: 'A slender redhead in leather' }, 'сгенерируй', {});
assert.equal(userGen.generate, true);
const vetoed = resolveTurnIntent({ prompt: 'fox', generate: true }, 'только запомни промпт', {});
assert.equal(vetoed.generate, false);
assert.equal(vetoed.vetoed, true);
const noHeuristic = resolveTurnIntent({ prompt: 'fox' }, 'нарисуй красивую лису в снегу', {});
assert.equal(noHeuristic.generate, false);
const chatOnly = resolveTurnIntent({ prompt: 'fox' }, 'что ты умеешь про генерацию?', {});
assert.equal(chatOnly.generate, false);
});
it('packWantsVision for critique pack', () => {
+35
View File
@@ -32,6 +32,30 @@ describe('patch.js', () => {
assert.equal(p.generate, true);
});
it('extractPatch prefers last terminal fence over a later weak pack fence', () => {
const text = [
'ok',
'```json',
'{"prompt":"A slender redhead in a leather jacket, 85mm, street light","generate":true}',
'```',
'```json',
'{"pack":"ordinary"}',
'```',
].join('\n');
const { patch } = extractPatch(text);
assert.equal(patch.generate, true);
assert.match(patch.prompt, /redhead/);
});
it('extractPatch accepts generate true string and unfenced object', () => {
const fenced = extractPatch('ok\n```json\n{"prompt":"A slender redhead in a leather jacket, 85mm","generate":"true"}\n```');
assert.equal(fenced.patch.generate, true);
assert.match(fenced.patch.prompt, /redhead/);
const raw = extractPatch('done\n{"prompt":"A fiery redhead in leather, street light","generate":true}');
assert.equal(raw.patch.generate, true);
assert.equal(raw.prose, 'done');
});
it('scheduler-only patch is detected with full key list', () => {
setPatchKeys(['prompt', 'scheduler', 'generate', 'ask']);
assert.equal(isPatchObject({ scheduler: 'euler' }), true);
@@ -77,6 +101,17 @@ describe('session.js', () => {
assert.equal(intent.vetoed, true);
});
it('resolveTurnIntent honors сгенерируй when prompt is present', () => {
const intent = resolveTurnIntent(
{ prompt: 'A slender redhead in a leather jacket' },
'сгенерируй',
{ askGenerateFn: (t) => /сгенер/i.test(t) },
);
assert.equal(intent.generate, true);
assert.equal(patchWantsGenerate({ generate: 'true' }), true);
assert.equal(patchWantsGenerate({ generate: true }), true);
});
it('mergeExactParamsForGenerate fills turbo profile when LLM omits steps/cfg', () => {
const exact = {
generation: { profile: 'turbo', steps: 8, cfg: 1, sigma_shift: 1.15 },