Chips and quick patches apply params without auto-Generate; aspect sizes match Swarm Side Length 1024. HF import shows drafts after switching filter from approved-only; training status and job polling improved. Co-authored-by: Cursor <cursoragent@cursor.com>
456 lines
14 KiB
JavaScript
456 lines
14 KiB
JavaScript
/**
|
|
* 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) : [];
|
|
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') {
|
|
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;
|
|
}
|
|
const n = normalizeDelta(patch);
|
|
if (n?.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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate is user-owned. Model generate:true is advisory (Qwen dumps it on chat questions).
|
|
* askGenerateFn / userWantsGenerate start Generate; vetoFn / fromAutoCritique cancel.
|
|
*/
|
|
export function resolveTurnIntent(patch, userText, {
|
|
vetoFn, askGenerateFn, fromAutoCritique, sessionPrompt, userWantsGenerate,
|
|
} = {}) {
|
|
const delta = normalizeDelta(patch) || {};
|
|
const vetoed = typeof vetoFn === 'function' ? !!vetoFn(userText) : false;
|
|
const modelAsked = patchWantsGenerate(delta);
|
|
const hasPrompt = !!(String(delta.prompt || '').trim() || String(sessionPrompt || '').trim());
|
|
const userAsked = typeof askGenerateFn === 'function' && !!askGenerateFn(userText)
|
|
&& (modelAsked || hasPrompt);
|
|
const generate = !vetoed && !fromAutoCritique && (userAsked || !!userWantsGenerate);
|
|
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, modelAsked };
|
|
}
|
|
|
|
/** Params the client fills from Exact on Generate when the LLM omits them (sparse contract). */
|
|
export const EXACT_GENERATE_PARAM_KEYS = ['steps', 'cfg', 'sigma_shift'];
|
|
|
|
/**
|
|
* Turbo/RAW profile numbers from Exact — no sessionExact overlay.
|
|
* profileName should already reflect the live checkpoint (turbo vs raw).
|
|
*/
|
|
export function resolveExactProfileDefaults({ exact, profiles, profileName } = {}) {
|
|
const gen = exact?.generation && typeof exact.generation === 'object' ? exact.generation : {};
|
|
const profile = profileName || gen.profile || 'turbo';
|
|
const fromProfile = profiles?.[profile] && typeof profiles[profile] === 'object'
|
|
? profiles[profile]
|
|
: {};
|
|
return {
|
|
profile,
|
|
steps: fromProfile.steps ?? gen.steps ?? null,
|
|
cfg: fromProfile.cfg ?? gen.cfg ?? null,
|
|
sigma_shift: fromProfile.sigma_shift ?? gen.sigma_shift ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* On generate, inject Exact profile steps/cfg/sigma when omitted, and overwrite foreign leftovers when the user did not ask for params.
|
|
* Client is authoritative so sparse LLM deltas are safe. Honors explicit patch values
|
|
* and sessionExact when the user asked for params this turn.
|
|
* Returns clearSessionKeys so the UI apply path is not blocked by stale sessionExact.
|
|
*/
|
|
export function mergeExactParamsForGenerate(patch, {
|
|
exact,
|
|
profiles,
|
|
profileName,
|
|
sessionExact,
|
|
userParamIntent,
|
|
} = {}) {
|
|
if (!patchWantsGenerate(patch)) {
|
|
return { patch, clearSessionKeys: [], profile: null };
|
|
}
|
|
const defaults = resolveExactProfileDefaults({ exact, profiles, profileName });
|
|
const out = { ...patch };
|
|
const clearSessionKeys = [];
|
|
for (const key of EXACT_GENERATE_PARAM_KEYS) {
|
|
if (out[key] != null) {
|
|
// 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];
|
|
continue;
|
|
}
|
|
if (defaults[key] != null) {
|
|
out[key] = defaults[key];
|
|
if (sessionExact?.[key] != null && String(sessionExact[key]) !== String(defaults[key])) {
|
|
clearSessionKeys.push(key);
|
|
}
|
|
}
|
|
}
|
|
return { patch: out, clearSessionKeys, profile: defaults.profile };
|
|
}
|
|
|
|
export function attachSession(SA) {
|
|
SA.session = {
|
|
emptySession,
|
|
normalizeDelta,
|
|
mergeDelta,
|
|
patchWantsGenerate,
|
|
patchAskList,
|
|
snapshotFromLive,
|
|
sessionFromLegacyParams,
|
|
toPersistParams,
|
|
compactContext,
|
|
fullSettingsDump,
|
|
resolveTurnIntent,
|
|
resolveExactProfileDefaults,
|
|
mergeExactParamsForGenerate,
|
|
EXACT_GENERATE_PARAM_KEYS,
|
|
GEN_KEYS,
|
|
};
|
|
}
|