Ship Assistent 0.14.0: chat sessions, ask-only hops, and context compression.

Per-chat Generate session with sparse deltas; drop Cards/Civitai/wanted hops; rolling history summary via the same Ollama model with a budget chip and /compress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 06:19:04 +03:00
co-authored by Cursor
parent d29436c944
commit 9ac24a828b
35 changed files with 5640 additions and 5652 deletions
+263
View File
@@ -0,0 +1,263 @@
/**
* Turn activity timeline — shows model commands and pipeline steps in chat.
* Cursor-like, but compact and chat-native.
*/
const STEP_ICONS = {
think: '◇',
stream: '✎',
delta: '⇢',
ask: '?',
look: '◎',
prep: '↻',
generate: '▷',
merge: '⊕',
warm: '▲',
park: '▼',
inventory: '▤',
compress: '▤',
done: '✓',
skip: '',
error: '!',
};
export function createActivityController(opts = {}) {
const {
getMessagesEl,
scrollToBottom,
hideEmpty,
} = opts;
let card = null;
let listEl = null;
let titleEl = null;
let steps = [];
let open = true;
function ensureCard() {
const box = typeof getMessagesEl === 'function' ? getMessagesEl() : null;
if (!box) {
return null;
}
if (card && card.isConnected) {
return card;
}
if (typeof hideEmpty === 'function') {
hideEmpty();
}
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';
head.setAttribute('aria-expanded', 'true');
const spin = document.createElement('span');
spin.className = 'sa-activity-spin';
spin.setAttribute('aria-hidden', 'true');
titleEl = document.createElement('span');
titleEl.className = 'sa-activity-title';
titleEl.textContent = 'Assistent';
const chev = document.createElement('span');
chev.className = 'sa-activity-chev';
chev.setAttribute('aria-hidden', 'true');
chev.textContent = '▾';
head.appendChild(spin);
head.appendChild(titleEl);
head.appendChild(chev);
head.addEventListener('click', () => {
open = !open;
card.classList.toggle('sa-activity-collapsed', !open);
head.setAttribute('aria-expanded', open ? 'true' : 'false');
});
listEl = document.createElement('div');
listEl.className = 'sa-activity-steps';
card.appendChild(head);
card.appendChild(listEl);
box.appendChild(card);
if (typeof scrollToBottom === 'function') {
scrollToBottom();
}
return card;
}
function renderStep(step) {
const row = document.createElement('div');
row.className = `sa-activity-step sa-activity-${step.status || 'running'}`;
row.dataset.id = step.id;
const icon = document.createElement('span');
icon.className = 'sa-activity-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think;
const body = document.createElement('div');
body.className = 'sa-activity-body';
const label = document.createElement('div');
label.className = 'sa-activity-label';
label.textContent = step.label || step.id;
body.appendChild(label);
if (step.detail) {
const detail = document.createElement('div');
detail.className = 'sa-activity-detail';
detail.textContent = step.detail;
body.appendChild(detail);
}
row.appendChild(icon);
row.appendChild(body);
return row;
}
function paint() {
if (!ensureCard() || !listEl) {
return;
}
listEl.replaceChildren(...steps.map(renderStep));
const running = steps.find((s) => s.status === 'running');
const last = steps[steps.length - 1];
if (titleEl) {
titleEl.textContent = running
? running.label
: (last?.label || 'Assistent');
}
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 = [];
card = null;
listEl = null;
titleEl = null;
open = true;
ensureCard();
if (titleEl && title) {
titleEl.textContent = title;
}
paint();
}
function upsert(id, patch) {
ensureCard();
let step = steps.find((s) => s.id === id);
if (!step) {
step = { id, kind: 'think', label: id, status: 'running', detail: '' };
steps.push(step);
}
Object.assign(step, patch);
if (!step.status) {
step.status = 'running';
}
paint();
return step;
}
function done(id, patch = {}) {
return upsert(id, { ...patch, status: 'done' });
}
function skip(id, patch = {}) {
return upsert(id, { ...patch, status: 'skip' });
}
function fail(id, patch = {}) {
return upsert(id, { ...patch, status: 'error' });
}
function finish(summary) {
steps.forEach((s) => {
if (s.status === 'running') {
s.status = 'done';
}
});
if (summary && titleEl) {
titleEl.textContent = summary;
}
paint();
if (card) {
card.classList.remove('sa-activity-live');
card.classList.add('sa-activity-done');
}
}
/** Describe a sparse model patch as human-readable activity steps. */
function noteModelCommands(patch) {
if (!patch || typeof patch !== 'object') {
return;
}
const keys = Object.keys(patch).filter((k) => patch[k] != null
&& !['actions', 'generate', 'ask', 'look_at', 'vision_from', 'vision_slots', 'notes', 'variants'].includes(k));
if (keys.length) {
done('delta', {
kind: 'delta',
label: 'Обновил сессию',
detail: keys.slice(0, 10).join(', '),
});
}
const ask = Array.isArray(patch.ask) ? patch.ask.map(String) : (patch.ask ? [String(patch.ask)] : []);
if (ask.length) {
upsert('ask', {
kind: 'ask',
label: `Запросил ${ask.join(', ')}`,
detail: 'подгружаю детали…',
status: 'running',
});
}
if (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null) {
const slots = [].concat(patch.look_at || patch.vision_from || patch.vision_slots || []);
upsert('look', {
kind: 'look',
label: 'Смотрит на кадр',
detail: slots.map(String).slice(0, 4).join(', '),
status: 'running',
});
}
if (patch.generate === true
|| (Array.isArray(patch.actions) && patch.actions.map(String).includes('generate'))) {
upsert('generate', {
kind: 'generate',
label: 'Generate',
detail: 'ждёт пайплайн…',
status: 'running',
});
}
if (Array.isArray(patch.variants) && patch.variants.length) {
upsert('variants', {
kind: 'generate',
label: `Варианты ×${patch.variants.length}`,
status: 'running',
});
}
}
return {
begin,
upsert,
done,
skip,
fail,
finish,
noteModelCommands,
get steps() {
return steps.slice();
},
};
}
export function attachActivity(SA) {
SA.createActivityController = createActivityController;
}
+1051 -1540
View File
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
/**
* Context budget + conversation memory helpers for chat compression.
*/
export function emptyContextMemory() {
return {
summary: '',
untilCount: 0,
foldedTurns: 0,
at: 0,
uiCollapsed: false,
promptEvalCount: null,
};
}
export function normalizeContextMemory(raw) {
if (!raw || typeof raw !== 'object') {
return emptyContextMemory();
}
const summary = String(raw.summary || '').trim();
return {
summary,
untilCount: Math.max(0, Number(raw.untilCount) || 0),
foldedTurns: Math.max(0, Number(raw.foldedTurns) || 0),
at: Number(raw.at) || 0,
uiCollapsed: !!raw.uiCollapsed && !!summary,
promptEvalCount: raw.promptEvalCount != null ? Number(raw.promptEvalCount) || null : null,
};
}
/** Estimate tokens from character counts. */
export function charsToTokens(chars, charsPerToken = 3.2) {
const cpt = Math.max(1.5, Number(charsPerToken) || 3.2);
const n = Math.max(0, Number(chars) || 0);
return Math.ceil(n / cpt);
}
/**
* @param {{
* systemChars?: number,
* historyChars?: number,
* memoryChars?: number,
* numCtx?: number,
* numPredict?: number,
* charsPerToken?: number,
* compressAt?: number,
* promptEvalCount?: number|null,
* }} opts
*/
export function estimateBudget(opts = {}) {
const numCtx = Math.max(1024, Number(opts.numCtx) || 16384);
const numPredict = Math.max(256, Number(opts.numPredict) || 3072);
const charsPerToken = Math.max(1.5, Number(opts.charsPerToken) || 3.2);
const compressAt = Math.min(0.95, Math.max(0.4, Number(opts.compressAt) || 0.7));
const systemChars = Math.max(0, Number(opts.systemChars) || 0);
const historyChars = Math.max(0, Number(opts.historyChars) || 0);
const memoryChars = Math.max(0, Number(opts.memoryChars) || 0);
const inputChars = systemChars + historyChars + memoryChars;
const estimated = charsToTokens(inputChars, charsPerToken);
const used = opts.promptEvalCount != null && Number(opts.promptEvalCount) > 0
? Number(opts.promptEvalCount)
: estimated;
const headroom = Math.max(1024, numCtx - numPredict);
const threshold = Math.floor(headroom * compressAt);
const ratio = numCtx > 0 ? used / numCtx : 0;
let level = 'ok';
if (ratio >= 0.85 || used >= threshold) {
level = 'hot';
} else if (ratio >= 0.65 || used >= threshold * 0.85) {
level = 'warn';
}
return {
numCtx,
numPredict,
headroom,
threshold,
systemChars,
historyChars,
memoryChars,
inputChars,
estimated,
used,
fromEval: opts.promptEvalCount != null && Number(opts.promptEvalCount) > 0,
ratio,
level,
charsPerToken,
compressAt,
};
}
/**
* Whether auto-compress should run before the next chat turn.
* @param {ReturnType<typeof estimateBudget>} budget
* @param {{ untilCount?: number, summary?: string }} memory
* @param {number} historyLen — full transcript length
* @param {{ keepMessages?: number }} opts — messages kept raw (turns*2)
*/
export function shouldCompress(budget, memory, historyLen, opts = {}) {
const keep = Math.max(2, Number(opts.keepMessages) || 8);
const len = Math.max(0, Number(historyLen) || 0);
const until = Math.max(0, Number(memory?.untilCount) || 0);
const uncovered = Math.max(0, len - until);
if (uncovered <= keep) {
return false;
}
const used = budget?.used ?? 0;
const threshold = budget?.threshold ?? Infinity;
return used >= threshold;
}
/**
* Messages the model should see: optional covered-by-summary skip + last keep raw.
* @param {Array<{role:string,content?:string,systemish?:boolean}>} history
* @param {{ untilCount?: number }} memory
* @param {number} keepTurns
*/
export function assembleModelMessages(history, memory, keepTurns) {
const keep = Math.max(1, Number(keepTurns) || 4) * 2;
const until = Math.max(0, Number(memory?.untilCount) || 0);
const list = (history || []).filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish);
const afterSummary = until > 0 ? list.slice(until) : list;
const window = afterSummary.length > keep ? afterSummary.slice(-keep) : afterSummary;
return window.map((m) => ({
role: m.role,
content: String(m.content || '').slice(0, 4000),
}));
}
/** How many leading messages can be folded into a new summary (leave keep raw). */
export function messagesToFold(history, memory, keepTurns) {
const keep = Math.max(1, Number(keepTurns) || 4) * 2;
const list = (history || []).filter((m) => m && (m.role === 'user' || m.role === 'assistant') && !m.systemish);
const until = Math.max(0, Number(memory?.untilCount) || 0);
const foldEnd = Math.max(until, list.length - keep);
if (foldEnd <= until) {
return [];
}
return list.slice(until, foldEnd);
}
export function mergeSummary(oldSummary, incoming) {
const next = String(incoming || '').trim();
if (!next) {
return String(oldSummary || '').trim();
}
const prev = String(oldSummary || '').trim();
if (!prev) {
return next;
}
// Prefer the model output when it already incorporates prior memory.
return next;
}
export function formatTokenShort(n) {
const v = Math.max(0, Number(n) || 0);
if (v >= 10000) {
return `${(v / 1000).toFixed(1)}k`;
}
if (v >= 1000) {
return `${(v / 1000).toFixed(1)}k`;
}
return String(Math.round(v));
}
export function conversationMemoryBlock(memory, maxChars = 2400) {
const m = normalizeContextMemory(memory);
if (!m.summary) {
return null;
}
let text = m.summary;
if (text.length > maxChars) {
text = `${text.slice(0, maxChars)}`;
}
return {
summary: text,
until_count: m.untilCount,
folded_turns: m.foldedTurns,
};
}
export function attachContext(SA) {
SA.context = {
emptyContextMemory,
normalizeContextMemory,
charsToTokens,
estimateBudget,
shouldCompress,
assembleModelMessages,
messagesToFold,
mergeSummary,
formatTokenShort,
conversationMemoryBlock,
};
}
+21 -140
View File
@@ -1,4 +1,4 @@
/** Turn intent heuristics — pure functions testable with node --test. */
/** Turn intent — veto only; generate comes from model `generate: true` (or legacy actions). */
export function cyrTokenRe(alts) {
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
@@ -6,55 +6,9 @@ export function cyrTokenRe(alts) {
return new RegExp(`${boundary}(?:${alts})${end}`, 'i');
}
export function userAsksGenerate(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(gen|generate|go|рисуй|нарисуй)([!.…\s]|$)/i.test(t)) {
return true;
}
if (/^(ещё|еще)(\s+раз)?([!.…\s]|$)/i.test(t)) {
return true;
}
const letter = '[0-9A-Za-z_А-Яа-яЁё]';
const stem = `${letter}*`;
return cyrTokenRe(
'сгенерируй|сгенерировать|генерируй|generate|нарисуй|перегенерируй|перерисуй|'
+ `сделай\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ `хочу\\s+(картинк${stem}|изображен${stem}|фото${stem})|`
+ 'run\\s+generat|/gen',
).test(t);
}
export function userAsksContinue(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(давай\s+дальше|продолжай|продолжим|go\s+on|continue|keep\s+going|next(\s+one)?|next\s+frame)([!.…\s]|$)/i.test(t)) {
return true;
}
return cyrTokenRe(
'давай\\s+дальше|следующ(ий|ая|ее|ую)\\s+кадр|ещё\\s+кадр|еще\\s+кадр|'
+ 'кадр\\s*№?\\s*\\d+|сделай\\s+следующ',
).test(t);
}
export function isSameButAspectRequest(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
return cyrTokenRe(
'тот\\s+же\\s+(кадр|сцена|промпт|prompt)|так\\s+же\\s+но\\s+(друг|иной)\\s+(формат|размер|aspect|соотношен)|'
+ 'same\\s+but\\s+(wider|taller|16:9|4:3|portrait|landscape)',
).test(t);
}
export function userAsksNoGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksGenerate(t)) {
if (!t) {
return false;
}
if (/\b(remember|save\s+(this\s+)?(as\s+)?(the\s+)?(base\s+)?(prompt|template)|don'?t\s+generat|do\s+not\s+generat|no\s+generat|without\s+generat)\b/i.test(t)) {
@@ -63,19 +17,12 @@ export function userAsksNoGenerate(text) {
return cyrTokenRe(
'запомн|запомни|запомним|сохрани|сохраним|шаблон|'
+ 'базов(ый|ого|ому|ым|ая|ую|ое)?\\s+промпт|'
+ 'не\\s+генерир|без\\s+генерац|не\\s+надо\\s+генер|только\\s+запомн|пока\\s+запомн|'
+ 'не\\s+рисуй|не\\s+запускай\\s+генер',
+ 'не\\s+генерир[а-яё]*|без\\s+генерац[а-яё]*|не\\s+надо\\s+генер[а-яё]*|только\\s+запомн[а-яё]*|пока\\s+запомн[а-яё]*|'
+ 'не\\s+рисуй|не\\s+запускай\\s+генер[а-яё]*|'
+ 'только\\s+(ответь|скажи|объясни)',
).test(t);
}
export function userCommandsGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t)) {
return false;
}
return userAsksGenerate(t) || userAsksContinue(t) || isSameButAspectRequest(t);
}
export function userAsksLook(text) {
const t = String(text || '').trim();
if (!t) {
@@ -93,68 +40,15 @@ export function userAsksLook(text) {
return /(?:^|[^а-яёa-z0-9_])(посмотри|смотри|глянь)\s+(на\s+)?(результат|картинк[а-яё]*|изображен[а-яё]*|кадр|ген|реф)/i.test(t);
}
export function userIsChatNotFrame(text) {
export function isSameButAspectRequest(text) {
const t = String(text || '').trim();
if (!t) {
return false;
}
if (/^(ок|окей|ok|okay|ладно|хорошо|понял|ясно|спасибо|thanks)([!.…\s]*)$/i.test(t)) {
return true;
}
if (cyrTokenRe(
'что\\s+такое|как\\s+работает|зачем\\s+|какие\\s+(лор|модел|чекпоинт)|'
+ 'список\\s+лор|где\\s+настрой|что\\s+значит|'
+ 'нравит|спасибо|благодар|почему\\s+так|что\\s+ты\\s+(сделал|изменил)|'
+ 'только\\s+(ответь|скажи|объясни)|без\\s+(кадр|генерац)|не\\s+надо\\s+кадр',
).test(t) && !userAsksGenerate(t) && !userAsksContinue(t)) {
return true;
}
return false;
}
export function userImpliesGenerate(text) {
const t = String(text || '').trim();
if (!t || userAsksNoGenerate(t) || userIsChatNotFrame(t)) {
return false;
}
if (userCommandsGenerate(t)) {
return true;
}
if (t.length < 8) {
return false;
}
const wantsLook = userAsksLook(t);
const wantsRedraw = cyrTokenRe('поправь|исправь|перегенерир|перерисуй|улучши|переделай').test(t)
|| /\b(fix|redo|redraw|improve)\b/i.test(t);
if (wantsLook && !wantsRedraw) {
return false;
}
if (cyrTokenRe(
'нарису|сгенер|перерису|'
+ 'сделай\\s+(картинк|изображен|фото|кадр)|'
+ 'хочу\\s+(картинк|изображен|фото|увидеть|видеть)|'
+ 'покажи\\s+как\\s+(она|он|это)|'
+ 'сделай\\s+(её|ее|его|мне)\\s|'
+ 'пусть\\s+будет|'
+ 'другой\\s+(ракурс|свет|наряд|поза)|'
+ 'поменяй\\s+(позу|свет|одежд|фон)|добавь\\s+(свет|детал)|'
+ 'ещё\\s+одн|еще\\s+одн',
).test(t)) {
return true;
}
if (/\b(draw|paint|render|make her|make him|another one|new frame)\b/i.test(t)) {
return true;
}
const isQuestion = /[?]\s*$/.test(t);
if (isQuestion) {
return cyrTokenRe('нарису|сгенер|можешь\\s+(сделать|нарисовать)|можно\\s+(картинк|сгенер)').test(t);
}
return false;
}
export function packBlocksAutoGenerate(pack) {
const p = String(pack || '');
return p === 'describe_ref' || p === 'catalog_card' || p === 'author_persona' || p === 'debug_explain';
return cyrTokenRe(
'тот\\s+же\\s+(кадр|сцена|промпт|prompt)|так\\s+же\\s+но\\s+(друг|иной)\\s+(формат|размер|aspect|соотношен)|'
+ 'same\\s+but\\s+(wider|taller|16:9|4:3|portrait|landscape)',
).test(t);
}
export function packWantsVision(pack) {
@@ -162,30 +56,17 @@ export function packWantsVision(pack) {
return p === 'critique_image' || p === 'describe_ref' || p === 'compose_scene' || p === 'inpaint_edit';
}
export function resolveTurnIntent(patch, userText, opts = {}, packId = '') {
const machine = !!opts.machineTurn;
const vetoed = !machine && userAsksNoGenerate(userText);
const commanded = !!opts.userWantsGenerate || (!machine && userCommandsGenerate(userText));
const implied = !machine && userImpliesGenerate(userText);
const modelAsked = Array.isArray(patch?.actions) && patch.actions.map(String).includes('generate');
let generate;
if (vetoed || opts.fromAutoCritique) {
generate = false;
} else if (commanded) {
generate = true;
} else if (packBlocksAutoGenerate(packId)) {
generate = false;
} else {
generate = modelAsked || implied;
}
/** Model generate + explicit veto. No RU imply/command heuristics. */
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 hasLook = !!patch
&& (patch.look_at != null || patch.vision_from != null || patch.vision_slots != null);
const honorLook = opts.fromAutoCritique || opts.fromVisionHop
|| (!machine && userAsksLook(userText))
|| packWantsVision(packId);
const look = !!(hasLook && !vetoed && !generate && honorLook);
return { generate, look, vetoed };
const look = !!(hasLook && !vetoed && !generate);
const ask = Array.isArray(patch?.ask)
? patch.ask.map(String)
: (typeof patch?.ask === 'string' && patch.ask ? [patch.ask] : []);
return { generate, look, vetoed, ask };
}
+6
View File
@@ -1,11 +1,17 @@
import { attachApi } from './api.js';
import { attachPatch, setPatchKeys } from './patch.js';
import { attachPersist } from './persist.js';
import { attachSession } from './session.js';
import { attachContext } from './context.js';
import { attachActivity } from './activity.js';
window.SA = window.SA || {};
attachApi(window.SA);
attachPatch(window.SA);
attachPersist(window.SA);
attachSession(window.SA);
attachContext(window.SA);
attachActivity(window.SA);
/** Called from app after AssistentGetConfig — single source: Config/_base/patch-keys.json */
window.SA.applyConfigPatchKeys = function (config) {
+19 -39
View File
@@ -1,17 +1,15 @@
/** Patch detection / extraction — mirrors AssistentPatch.cs. */
/** Patch detection / extraction — mirrors AssistentPatch.cs (0.14 sparse session deltas). */
const DEFAULT_PATCH_KEYS = [
'prompt', 'negative', 'loras', 'width', 'height', 'steps', 'cfg', 'seed', 'sigma_shift', 'sampler', 'scheduler',
'actions', 'search_query', 'civitai_query',
'actions', 'generate', 'ask',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
'look_at', 'vision_from', 'vision_slots', 'slot_to_init', 'slot_to_mask',
'snapshot_generate', 'select_slot', 'aspect', 'images', 'batch', 'vary', 'lock_seed',
'creativity', 'intensity', 'complexity', 'movement',
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'memories', 'memory',
'memory_query', 'memory_kind', 'tag_query', 'user_prefs',
'inventory_query', 'skills', 'persona_shelves', 'persona_clone', 'persona', 'controls',
'variants',
'clear_prompt_images', 'slot_to_prompt_image', 'pack', 'persona', 'controls',
'inventory_query', 'variants',
];
let PATCH_KEYS = DEFAULT_PATCH_KEYS.slice();
@@ -32,27 +30,10 @@ function has(obj, key) {
return obj[key] !== undefined && obj[key] !== null;
}
export function isCardObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const cardish = !!(obj.kind || obj.triggers || obj.when || obj.prompt_hint);
const genish = !!(obj.prompt != null || obj.negative != null || obj.loras || obj.actions
|| obj.width || obj.height || obj.steps != null || obj.cfg != null || obj.aspect || obj.seed != null
|| obj.search_query || obj.civitai_query || obj.look_at || obj.controls);
if (cardish && !genish && (obj.name || obj.triggers || obj.when)) {
return true;
}
return !!(obj.kind && obj.name && (obj.triggers || obj.when || obj.prompt_hint || obj.notes != null));
}
export function isPatchObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
if (isCardObject(obj)) {
return false;
}
return PATCH_KEYS.some((k) => has(obj, k));
}
@@ -60,8 +41,12 @@ export function normalizePatch(patch) {
if (!patch || typeof patch !== 'object') {
return patch;
}
if (!has(patch, 'search_query') && has(patch, 'civitai_query')) {
patch.search_query = patch.civitai_query;
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
if (patch.generate === true || acts.includes('generate')) {
patch.generate = true;
}
if (typeof patch.ask === 'string') {
patch.ask = [patch.ask];
}
if (!has(patch, 'init_creativity') && has(patch, 'denoise')) {
patch.init_creativity = patch.denoise;
@@ -100,7 +85,13 @@ export function isTerminalStreamPatch(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
if (isCardObject(obj)) {
if (obj.generate === true) {
return true;
}
if (Array.isArray(obj.ask) && obj.ask.length) {
return true;
}
if (typeof obj.ask === 'string' && obj.ask) {
return true;
}
if (Array.isArray(obj.variants) && obj.variants.length) {
@@ -109,17 +100,8 @@ export function isTerminalStreamPatch(obj) {
if (obj.look_at != null || obj.vision_from != null || obj.vision_slots != null) {
return true;
}
if (obj.search_query != null || obj.civitai_query != null
|| obj.memory_query != null || obj.tag_query != null || obj.inventory_query != null) {
return true;
}
const acts = Array.isArray(obj.actions) ? obj.actions.map(String) : [];
const hopOrGen = [
'skill_load', 'persona_read', 'memory_get', 'memory_search', 'lookup_tags',
'list_inventory', 'search_civitai', 'interrupt', 'generate',
'memory_upsert', 'user_pref_upsert',
];
if (acts.some((a) => hopOrGen.includes(a))) {
if (acts.includes('generate')) {
return true;
}
if (String(obj.prompt || '').trim().length >= 48) {
@@ -127,8 +109,7 @@ export function isTerminalStreamPatch(obj) {
}
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.memories != null || obj.user_prefs != null) {
|| obj.seed != null || obj.controls != null) {
return true;
}
return false;
@@ -137,7 +118,6 @@ export function isTerminalStreamPatch(obj) {
export function attachPatch(SA) {
SA.PATCH_KEYS = PATCH_KEYS;
SA.setPatchKeys = setPatchKeys;
SA.isCardObject = isCardObject;
SA.isPatchObject = isPatchObject;
SA.isTerminalStreamPatch = isTerminalStreamPatch;
SA.normalizePatch = normalizePatch;
+368
View File
@@ -0,0 +1,368 @@
/**
* Per-chat generation session — source of truth for Generate params, board, LoRAs.
* Sparse model deltas merge into the active session; buttons always generate from it.
*/
const MAX_DATA_URL_CHARS = 350_000;
const GEN_KEYS = [
'prompt', 'negative', 'width', 'height', 'aspect', 'steps', 'cfg', 'sigma_shift',
'seed', 'sampler', 'scheduler', 'batch', 'checkpoint', 'loras', 'controls',
'use_init_image', 'clear_init_image', 'init_creativity', 'denoise',
'use_mask_image', 'clear_mask_image', 'mask_blur', 'mask_grow',
];
export function emptySession() {
return {
gen: {
prompt: '',
negative: '',
width: null,
height: null,
aspect: null,
steps: null,
cfg: null,
sigma_shift: null,
seed: null,
sampler: null,
scheduler: null,
batch: null,
checkpoint: null,
loras: [],
controls: {},
use_init_image: false,
clear_init_image: false,
init_creativity: null,
denoise: null,
use_mask_image: false,
clear_mask_image: false,
mask_blur: null,
mask_grow: null,
},
board: {
slots: [],
selectedSlotId: 'ref1',
genResults: [],
selectedGenResultId: null,
refSeq: 1,
},
persona: 'neutral',
pack: 'ordinary',
context_memory: null,
};
}
/** Normalize boolean generate + legacy actions:["generate"]. */
export function normalizeDelta(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const delta = { ...raw };
const acts = Array.isArray(delta.actions) ? delta.actions.map(String) : [];
if (delta.generate === true || acts.includes('generate')) {
delta.generate = true;
}
if (typeof delta.ask === 'string') {
delta.ask = [delta.ask];
}
if (!Array.isArray(delta.ask)) {
delete delta.ask;
} else {
delta.ask = delta.ask.map(String).filter(Boolean);
}
return delta;
}
export function patchWantsGenerate(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
if (patch.generate === true) {
return true;
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
return acts.includes('generate');
}
export function patchAskList(patch) {
const n = normalizeDelta(patch);
return Array.isArray(n?.ask) ? n.ask : [];
}
/**
* Merge sparse model delta into session.gen (and pack/persona if present).
* Does not run Generate — caller decides via patchWantsGenerate + veto.
*/
export function mergeDelta(session, rawDelta) {
const base = session && typeof session === 'object' ? structuredCloneSession(session) : emptySession();
const delta = normalizeDelta(rawDelta);
if (!delta) {
return base;
}
if (!base.gen) {
base.gen = emptySession().gen;
}
for (const key of GEN_KEYS) {
if (delta[key] === undefined || delta[key] === null) {
continue;
}
if (key === 'loras' && Array.isArray(delta.loras)) {
base.gen.loras = delta.loras.map((l) => ({
name: l?.name || l,
weight: l?.weight != null ? Number(l.weight) : 1,
triggers: Array.isArray(l?.triggers) ? l.triggers : undefined,
trigger_phrase: l?.trigger_phrase || undefined,
})).filter((l) => l.name);
continue;
}
if (key === 'controls' && typeof delta.controls === 'object') {
base.gen.controls = { ...(base.gen.controls || {}), ...delta.controls };
continue;
}
if (key === 'checkpoint') {
base.gen.checkpoint = typeof delta.checkpoint === 'object'
? { ...delta.checkpoint }
: { name: String(delta.checkpoint) };
continue;
}
base.gen[key] = delta[key];
}
if (delta.images != null && delta.batch == null) {
base.gen.batch = delta.images;
}
if (delta.pack) {
base.pack = String(delta.pack);
}
if (delta.persona) {
base.persona = String(delta.persona);
}
return base;
}
function structuredCloneSession(session) {
try {
return JSON.parse(JSON.stringify(session));
} catch {
return emptySession();
}
}
function slimSrc(src) {
if (!src || typeof src !== 'string') {
return null;
}
const s = src.trim();
if (!s || s.startsWith('#')) {
return null;
}
if (s.startsWith('data:') && s.length > MAX_DATA_URL_CHARS) {
return null;
}
return s;
}
/** Snapshot live UI + board into a persistable session object. */
export function snapshotFromLive({
genFields,
board,
persona,
pack,
context_memory,
}) {
const session = emptySession();
if (genFields && typeof genFields === 'object') {
for (const key of GEN_KEYS) {
if (genFields[key] !== undefined) {
session.gen[key] = genFields[key];
}
}
}
session.persona = persona || 'neutral';
session.pack = pack || 'ordinary';
if (context_memory && typeof context_memory === 'object') {
session.context_memory = context_memory;
}
if (board && typeof board === 'object') {
session.board = {
slots: (board.slots || []).map((s) => ({
id: s.id,
type: s.type,
label: s.label,
src: slimSrc(s.src),
attach: !!s.attach,
note: s.note || null,
})),
selectedSlotId: board.selectedSlotId || 'ref1',
genResults: (board.genResults || []).map((r) => ({
id: r.id,
label: r.label,
src: slimSrc(r.src),
patch: r.patch || null,
})),
selectedGenResultId: board.selectedGenResultId || null,
refSeq: board.refSeq || 1,
};
}
return session;
}
/** Convert legacy flat chat.params into session shape. */
export function sessionFromLegacyParams(params) {
if (!params || typeof params !== 'object') {
return emptySession();
}
if (params.gen && typeof params.gen === 'object') {
const s = emptySession();
s.gen = { ...s.gen, ...params.gen };
if (params.board && typeof params.board === 'object') {
s.board = { ...s.board, ...params.board };
}
s.persona = params.persona || s.persona;
s.pack = params.pack || s.pack;
if (params.context_memory && typeof params.context_memory === 'object') {
s.context_memory = params.context_memory;
}
return s;
}
const s = emptySession();
for (const key of GEN_KEYS) {
if (params[key] !== undefined && params[key] !== null) {
s.gen[key] = params[key];
}
}
if (Array.isArray(params.loras)) {
s.gen.loras = params.loras;
}
if (params.checkpoint) {
s.gen.checkpoint = typeof params.checkpoint === 'object'
? params.checkpoint
: { name: String(params.checkpoint) };
}
s.persona = params.persona || 'neutral';
s.pack = params.pack || 'ordinary';
if (params.context_memory && typeof params.context_memory === 'object') {
s.context_memory = params.context_memory;
}
s.board.genResults = Array.isArray(params.genResults) ? params.genResults : [];
s.board.selectedGenResultId = params.selectedGenResultId || null;
if (Array.isArray(params.slots)) {
s.board.slots = params.slots;
}
if (params.selectedSlotId) {
s.board.selectedSlotId = params.selectedSlotId;
}
if (params.refSeq) {
s.board.refSeq = params.refSeq;
}
return s;
}
/** Persist blob for AssistentSaveChat.params */
export function toPersistParams(session) {
const s = session && typeof session === 'object' ? session : emptySession();
const out = {
gen: s.gen || emptySession().gen,
board: s.board || emptySession().board,
persona: s.persona || 'neutral',
pack: s.pack || 'ordinary',
};
if (s.context_memory && typeof s.context_memory === 'object') {
out.context_memory = s.context_memory;
}
return out;
}
function slimText(t, max) {
const s = String(t || '');
if (s.length <= max) {
return s;
}
return `${s.slice(0, max)}`;
}
/** Compact context for every LLM turn. */
export function compactContext(session, extras = {}) {
const s = session && typeof session === 'object' ? session : emptySession();
const g = s.gen || {};
const board = s.board || {};
const slots = board.slots || [];
const genSlot = slots.find((x) => x.type === 'generate' || x.id === 'generate');
const refs = slots.filter((x) => x.type === 'ref' || String(x.id || '').startsWith('ref'));
return {
session: true,
prompt: slimText(g.prompt, extras.promptMax || 2000),
negative: slimText(g.negative, 500),
aspect: g.aspect || null,
width: g.width ?? null,
height: g.height ?? null,
steps: g.steps ?? null,
cfg: g.cfg ?? null,
seed: g.seed ?? null,
sigma_shift: g.sigma_shift ?? null,
sampler: g.sampler || null,
scheduler: g.scheduler || null,
batch: g.batch ?? null,
checkpoint: g.checkpoint?.name || g.checkpoint || null,
selected_loras: (g.loras || []).map((l) => ({
name: l.name || l,
weight: l.weight != null ? l.weight : 1,
})),
persona: s.persona || 'neutral',
pack: s.pack || 'ordinary',
board: {
has_generate: !!(genSlot?.src || (board.genResults || []).some((r) => r.src)),
refs: refs.map((r) => ({ id: r.id, has_image: !!r.src, attach: !!r.attach })),
gen_results: (board.genResults || []).map((r) => ({
id: r.id,
label: r.label,
has_image: !!r.src,
selected: r.id === board.selectedGenResultId,
})),
selected_slot: board.selectedSlotId || null,
},
architecture_ok: extras.architecture_ok !== false,
...extras.extra,
};
}
/** Full dump for ask:settings hop. */
export function fullSettingsDump(session, extras = {}) {
const compact = compactContext(session, extras);
const s = session && typeof session === 'object' ? session : emptySession();
return {
...compact,
detail: 'settings',
gen: { ...(s.gen || {}) },
controls: s.gen?.controls || {},
exact: extras.exact || null,
krea_profiles: extras.kreaProfiles || null,
session_exact: extras.sessionExact || null,
};
}
export function resolveTurnIntent(patch, userText, { vetoFn } = {}) {
const delta = normalizeDelta(patch) || {};
const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false;
const generate = !vetoed && patchWantsGenerate(delta);
const hasLook = delta.look_at != null || delta.vision_from != null || delta.vision_slots != null;
const look = !!(hasLook && !generate && !vetoed);
const ask = patchAskList(delta);
return { generate, look, vetoed, ask };
}
export function attachSession(SA) {
SA.session = {
emptySession,
normalizeDelta,
mergeDelta,
patchWantsGenerate,
patchAskList,
snapshotFromLive,
sessionFromLegacyParams,
toPersistParams,
compactContext,
fullSettingsDump,
resolveTurnIntent,
GEN_KEYS,
};
}