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
+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];