Ship Assistent 0.15.3: user-owned Generate, SwarmUI aspects, HF import UX.

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>
This commit is contained in:
Leonid Pershin
2026-08-23 19:13:36 +03:00
co-authored by Cursor
parent e53f483a00
commit 65a0982b9d
24 changed files with 1088 additions and 275 deletions
+241 -99
View File
@@ -2,6 +2,8 @@
* Swarm Assistent Krea 2 collaborative chat (Ollama via Swarm API).
* v0.8.0: Split assets SA.request (assistent.api.js) and SA.*Patch (assistent.patch.js).
*/
import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } from './aspect.js';
(function () {
const LS_BASE = 'swarm_assistent_base_url';
const LS_MODEL = 'swarm_assistent_model';
@@ -35,16 +37,7 @@
let CHARS_PER_TOKEN = 3.2;
let COMPRESS_AUTO = true;
let ASPECT_TABLE = {
'1:1': [1024, 1024],
'4:3': [1184, 896],
'3:2': [1248, 832],
'16:9': [1376, 768],
'2.35:1': [1568, 672],
'4:5': [928, 1152],
'2:3': [832, 1248],
'9:16': [768, 1376],
};
let ASPECT_TABLE = { ...DEFAULT_ASPECT_TABLE };
let PACK_ALIASES = {
ordinary: 'ordinary',
@@ -127,6 +120,8 @@
lastUserControlIntent: false,
lastPatch: null,
pendingSilentGen: false,
/** This user turn asked to draw — hops inherit it; model generate:true does not. */
turnUserWantsGenerate: false,
pendingPromptEnMerge: null,
enabledSkills: [],
kreaProfiles: { turbo: { steps: 8, cfg: 1, sigma_shift: 1.15 }, raw: { steps: 28, cfg: 4.5 } },
@@ -698,6 +693,7 @@
let text = String(raw || '').replace(/\r\n/g, '\n');
text = text.replace(/(?:^|\n)#{1,6}\s*JSON\s*Patch\s*(?=\n|$)/gi, '\n');
text = text.replace(/(?:^|\n)\s*JSON\s*Patch\s*:?\s*(?=\n|$)/gi, '\n');
text = text.replace(/```(?:json)?\s*[\s\S]*?```/gi, '');
text = text.replace(/\n{3,}/g, '\n\n').trim();
if (!text) {
return '';
@@ -782,11 +778,32 @@
if (!el) {
return;
}
el.value = value;
const next = value == null ? '' : String(value);
if (el.value === next) {
return;
}
el.value = next;
// SwarmUI may auto-Generate on input/change — suppress during param-only chip edits.
if ((state._quietParamApply || 0) > 0) {
return;
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
const QUICK_PARAM_KEYS = new Set([
'aspect', 'width', 'height', 'steps', 'cfg', 'sigma_shift', 'seed',
'vary', 'lock_seed', 'sampler', 'scheduler', 'batch', 'images',
]);
function isParamOnlyQuickPatch(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
const keys = Object.keys(patch).filter((k) => patch[k] != null && k !== 'notes');
return keys.length > 0 && keys.every((k) => QUICK_PARAM_KEYS.has(k));
}
function liveNegativePrompt() {
return String(val('input_negativeprompt') || val('alt_negativeprompt_textbox') || '').trim();
}
@@ -1162,8 +1179,9 @@
return true;
}
return cyrTokenRe(
'сгенер[а-яё]*|нарисуй|нарисуйте|'
+ 'запусти\\s+генер[а-яё]*|сделай\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
'сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|'
+ 'запусти\\s+генер[а-яё]*|'
+ 'сдела(й|ем|йте)\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
).test(t);
}
@@ -1178,23 +1196,26 @@
vetoFn: userAsksNoGenerate,
askGenerateFn: userAsksGenerate,
fromAutoCritique: !!opts.fromAutoCritique,
sessionPrompt: opts.sessionPrompt || '',
userWantsGenerate: !!opts.userWantsGenerate,
});
}
// generate:true / actions / user «сгенерируй» when a prompt already exists.
// User «нарисуй» / hops — never model generate:true alone.
const vetoed = !isMachineTurn(opts) && userAsksNoGenerate(userText);
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 hasPrompt = !!(String(patch?.prompt || '').trim() || String(opts.sessionPrompt || '').trim());
const userAsked = !isMachineTurn(opts) && userAsksGenerate(userText)
&& !!(modelAsked || String(patch?.prompt || '').trim());
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked);
&& (modelAsked || hasPrompt);
const generate = !vetoed && !opts.fromAutoCritique && (userAsked || !!opts.userWantsGenerate);
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] : []);
return { generate, look, vetoed, ask };
return { generate, look, vetoed, ask, modelAsked };
}
function stripGenerateAction(patch) {
if (!patch || typeof patch !== 'object') {
@@ -1281,32 +1302,55 @@
badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit');
}
function syncLiveParamsBar() {
const el = $('sa_live_params');
if (!el) {
return;
}
function formatLiveParamsLine() {
const w = parseInt(val('input_width') || '0', 10) || null;
const h = parseInt(val('input_height') || '0', 10) || null;
const aspect = guessAspectFromSize(w, h) || '—';
const steps = val('input_steps') || '—';
const cfg = val('input_cfgscale') || val('input_cfg') || '—';
const sigma = val('input_sigmashift') || '';
const seed = val('input_seed') || '—';
const profile = detectKreaProfileName();
el.textContent = `${aspect} · ${w || '?'}×${h || '?'} · steps ${steps} · cfg ${cfg} · ${profile} · seed ${seed}`;
const batch = val('input_images') || val('input_batchsize') || '';
const sampler = val('input_sampler') || '';
const scheduler = val('input_scheduler') || '';
const parts = [
aspect,
`${w || '?'}×${h || '?'}`,
`steps ${steps}`,
`cfg ${cfg}`,
];
if (sigma) {
parts.push(`σ ${sigma}`);
}
parts.push(profile, `seed ${seed}`);
if (batch && batch !== '1') {
parts.push(`×${batch}`);
}
if (sampler) {
parts.push(sampler);
}
if (scheduler) {
parts.push(scheduler);
}
return parts.join(' · ');
}
function syncLiveParamsBar() {
const line = formatLiveParamsLine();
const boardEl = $('sa_live_params');
const composerEl = $('sa_composer_params');
if (boardEl) {
boardEl.textContent = line;
}
if (composerEl) {
composerEl.textContent = line;
}
}
function applyAspectTableFrom(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const next = {};
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v) && v.length >= 2) {
next[k] = [Number(v[0]), Number(v[1])];
}
}
if (!Object.keys(next).length) {
const next = mergeAspectTable(obj);
if (!next) {
return false;
}
ASPECT_TABLE = next;
@@ -2464,7 +2508,7 @@
actions.appendChild(toSession);
const genBtn = document.createElement('button');
genBtn.type = 'button';
genBtn.className = 'basic-button sa-btn-gen';
genBtn.className = 'basic-button sa-btn-gen sa-btn-gen-primary';
genBtn.textContent = 'Сгенерировать';
genBtn.addEventListener('click', async () => {
if (isGenerateUnavailable()) {
@@ -2477,6 +2521,9 @@
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), patch);
}
await pushSessionToSwarm(state.chatSession);
if (typeof appendSystemNote === 'function') {
appendSystemNote('Запускаю Generate');
}
await runGenerateFromPatch({ ...patch, actions: ['generate'] }, { force: true, fromSession: true });
});
actions.appendChild(genBtn);
@@ -2617,6 +2664,24 @@
return el;
}
/** Images available in the gen lightbox (variant grid or single live Generate slot). */
function genLightboxList() {
const fromResults = (state.genResults || []).filter((r) => r && r.src);
if (fromResults.length) {
return fromResults;
}
const slot = generateSlot();
if (slot?.src) {
return [{
id: slot.id || GEN_ID,
label: slot.label || 'Generate',
src: slot.src,
patch: state.lastPatch || null,
}];
}
return [];
}
function ensureGenLightbox() {
let root = $('sa_gen_lightbox');
if (root) {
@@ -2682,7 +2747,7 @@
}
function currentLightboxRow() {
const list = (state.genResults || []).filter((r) => r.src);
const list = genLightboxList();
if (!list.length || state.lightboxIndex < 0) {
return null;
}
@@ -2691,7 +2756,7 @@
function syncGenLightbox() {
const root = ensureGenLightbox();
const list = (state.genResults || []).filter((r) => r.src);
const list = genLightboxList();
const row = list[state.lightboxIndex];
if (!row) {
root.hidden = true;
@@ -2714,7 +2779,7 @@
}
function openGenLightbox(id) {
const list = (state.genResults || []).filter((r) => r.src);
const list = genLightboxList();
let idx = list.findIndex((r) => r.id === id);
if (idx < 0) {
idx = 0;
@@ -2737,7 +2802,7 @@
}
function stepGenLightbox(delta) {
const list = (state.genResults || []).filter((r) => r.src);
const list = genLightboxList();
if (list.length < 2) {
return;
}
@@ -2864,10 +2929,22 @@
busy.innerHTML = '<span class="sa-spinner" aria-hidden="true"></span>';
el.appendChild(busy);
el.addEventListener('click', () => {
el.addEventListener('click', (e) => {
if (slot.type === 'generate' && slot.src && e.target?.tagName === 'IMG') {
openGenLightbox(slot.id || GEN_ID);
return;
}
state.selectedSlotId = slot.id;
renderBoard();
});
if (slot.type === 'generate') {
el.addEventListener('dblclick', (e) => {
if (slot.src && e.target?.tagName === 'IMG') {
e.preventDefault();
openGenLightbox(slot.id || GEN_ID);
}
});
}
el.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
@@ -4218,6 +4295,14 @@
return { prose: text || '', patch: null };
}
function visibleAssistantProse(text) {
if (window.SA && typeof SA.visibleProse === 'function') {
return SA.visibleProse(text);
}
const { prose, patch } = extractPatch(text);
return patch ? (prose || '') : String(text || '');
}
function normalizeAspect(raw) {
if (raw == null) {
return null;
@@ -4926,7 +5011,7 @@
}
const persona = $('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral';
const pack = $('sa_pack')?.value || defaultPackId();
const prose = (typeof extractPatch === 'function' ? (extractPatch(text).prose || text) : text);
const prose = visibleAssistantProse(text);
if (state.streamEl) {
finalizeStreamMessage(text, []);
} else {
@@ -5500,7 +5585,7 @@
const { prose, patch: extracted } = role === 'assistant' ? extractPatch(text) : { prose: text, patch: null };
const finalPatch = patch || extracted;
if (role === 'assistant') {
setAssistantBody(div, prose || text || '');
setAssistantBody(div, finalPatch ? (prose || '') : (prose || text || ''));
} else {
div.textContent = prose || text || '';
}
@@ -5509,7 +5594,7 @@
const silent = !!(meta && meta.silentPatch);
mountPatchBlock(div, finalPatch, { silent });
}
if (role === 'assistant' && !(meta && meta.historical)) {
if (role === 'assistant' && !(meta && meta.historical)) {
mountCurateButtons(div, meta);
}
box.appendChild(div);
@@ -5611,7 +5696,7 @@ 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 });
setAssistantBody(state.streamEl, visibleAssistantProse(state.streamText), { live: true });
scrollMessagesToBottom();
const fn = state.onClosedTerminalFence;
state.onClosedTerminalFence = null;
@@ -5646,13 +5731,13 @@ if (role === 'assistant' && !(meta && meta.historical)) {
el.classList.remove('sa-streaming', 'sa-typing');
mountAssistantMeta(el, meta || undefined);
const { prose, patch } = extractPatch(fullReply);
setAssistantBody(el, prose || fullReply || '');
setAssistantBody(el, patch ? (prose || '') : (prose || fullReply || ''));
el.querySelectorAll('.sa-patch, .sa-civitai-list').forEach((n) => n.remove());
if (patch) {
const silent = !!(meta && meta.silentPatch) || !!state.pendingSilentGen;
mountPatchBlock(el, patch, { silent });
}
if (!(meta && meta.historical)) {
if (!(meta && meta.historical)) {
mountCurateButtons(el, meta);
}
scrollMessagesToBottom();
@@ -7812,8 +7897,32 @@ if (!(meta && meta.historical)) {
if (effective && act && typeof act.noteModelCommands === 'function') {
act.noteModelCommands(effective);
}
const sessionPrompt = String(state.chatSession?.gen?.prompt || '').trim();
const userWantsGenerate = !!opts.userWantsGenerate || !!state.turnUserWantsGenerate;
let intent = resolveTurnIntent(effective, opts.userText || '', {
...opts,
sessionPrompt,
userWantsGenerate,
});
if (userWantsGenerate && !intent.vetoed && !fromAutoCritique) {
intent = { ...intent, generate: true };
}
if (!effective && intent.generate && sessionPrompt) {
effective = { generate: true };
}
const promptChanged = !!(effective && String(effective.prompt || '').trim());
if (effective && S) {
if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
if (typeof doInterruptNow === 'function') doInterruptNow();
}
if (effective) {
if (intent.generate) {
const acts = Array.isArray(effective.actions) ? effective.actions.map(String) : [];
effective = { ...effective, actions: acts.includes('generate') ? acts : acts.concat('generate'), generate: true };
}
if (!intent.look && typeof stripLookAt === 'function') effective = stripLookAt(effective);
if (typeof rememberLastPatch === 'function') rememberLastPatch(effective);
}
if (intent.generate && effective && S) {
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
activityDone('delta', {
kind: 'delta',
@@ -7828,28 +7937,6 @@ if (!(meta && meta.historical)) {
if (typeof persistChatsStore === 'function') persistChatsStore();
}
} catch (e) { /* ignore */ }
if (typeof rememberLastPatch === 'function') rememberLastPatch(effective);
}
if (Array.isArray(effective?.actions) && effective.actions.map(String).includes('interrupt')) {
if (typeof doInterruptNow === 'function') doInterruptNow();
}
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) : [];
effective = { ...effective, actions: acts.includes('generate') ? acts : acts.concat('generate'), generate: true };
} else if (typeof stripGenerateAction === 'function') {
effective = stripGenerateAction(effective);
if (effective && effective.generate) {
effective = { ...effective };
delete effective.generate;
}
}
if (!intent.look && typeof stripLookAt === 'function') effective = stripLookAt(effective);
if (typeof rememberLastPatch === 'function') rememberLastPatch(effective);
}
if (intent.vetoed) {
state.pendingSilentGen = false;
@@ -7962,44 +8049,82 @@ if (!(meta && meta.historical)) {
if (typeof maybeAutoVisionLook === 'function') await maybeAutoVisionLook(srcOut);
}
} else if (effective) {
if (S) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
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' : (promptChanged ? 'Промпт обновлён' : ''));
stopBusyUi(intent.vetoed ? 'Запомнил · без Generate' : '');
}
}
state.pendingSilentGen = false;
reportDebugClientTurn({
user: opts.userText || '',
reply: String(reply || '').slice(0, 8000),
patch: effective,
intent,
generating: !!intent.generate,
swarm_prompt: val('alt_prompt_textbox') || val('input_prompt') || '',
persona: $('sa_persona')?.value || '',
pack: $('sa_pack')?.value || '',
});
}
async function applyQuickPatch(patch, note) {
function reportDebugClientTurn(payload) {
try {
const body = JSON.stringify({
ts: Date.now(),
...payload,
});
fetch('http://127.0.0.1:17821/assistent/client-event', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
mode: 'cors',
}).catch(() => {});
} catch (e) { /* sidecar optional */ }
}
async function applyQuickPatch(patch, note, opts = {}) {
const wantGenerate = opts.generate === true;
let withActions = { ...patch };
if (!Array.isArray(withActions.actions) && patchHasGenTrigger(withActions)) {
withActions.actions = ['generate'];
delete withActions.generate;
if (Array.isArray(withActions.actions)) {
withActions.actions = withActions.actions.filter((a) => String(a) !== 'generate');
if (!withActions.actions.length) {
delete withActions.actions;
}
}
if (wantGenerate) {
withActions.generate = true;
withActions.actions = [
...(Array.isArray(withActions.actions) ? withActions.actions : []),
'generate',
];
}
const prevIntent = state.lastUserParamIntent;
state.lastUserParamIntent = true;
const S = window.SA && window.SA.session;
if (S && S.patchWantsGenerate && S.patchWantsGenerate(withActions)) {
if (wantGenerate && S && S.patchWantsGenerate && S.patchWantsGenerate(withActions)) {
withActions = ensureExactParamsForGenerate(withActions);
}
if (S) {
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), withActions);
}
await pushSessionToSwarm(state.chatSession);
state._quietParamApply = (state._quietParamApply || 0) + 1;
try {
if (wantGenerate) {
await pushSessionToSwarm(state.chatSession);
} else if (isParamOnlyQuickPatch(withActions)) {
await applyPatch(withActions, 'params');
} else {
await pushSessionToSwarm(state.chatSession);
}
} finally {
state._quietParamApply = Math.max(0, (state._quietParamApply || 1) - 1);
}
state.lastUserParamIntent = prevIntent;
setStatus(note || 'Applied');
if (patchHasGenTrigger(withActions)) {
setStatus(note || (wantGenerate ? 'Applied' : 'Параметры (без Generate)'));
if (wantGenerate) {
await runGenerateFromPatch(withActions, { force: true, fromSession: true });
}
syncChipHighlight();
syncLiveParamsBar();
}
function syncChipHighlight() {
@@ -8275,7 +8400,7 @@ if (!(meta && meta.historical)) {
setStatus(`Unknown aspect. Try: ${Object.keys(ASPECT_TABLE).join(', ')}`);
return true;
}
await applyQuickPatch({ aspect: key, actions: ['generate'] }, `Aspect ${key}`);
await applyQuickPatch({ aspect: key }, `Aspect ${key}`);
return true;
}
if (cmd === 'seed') {
@@ -8283,12 +8408,12 @@ if (!(meta && meta.historical)) {
if (mode === 'lock' || mode === 'keep') {
await applyQuickPatch({ lock_seed: true }, 'Seed locked');
} else {
await applyQuickPatch({ seed: -1, vary: true, actions: ['generate'] }, 'Seed random');
await applyQuickPatch({ seed: -1, vary: true }, 'Seed random');
}
return true;
}
if (cmd === 'vary') {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary (new seed)');
await applyQuickPatch({ vary: true, seed: -1 }, 'Vary (new seed)', { generate: true });
return true;
}
if (cmd === 'inventory' || cmd === 'inv') {
@@ -8411,6 +8536,7 @@ if (!(meta && meta.historical)) {
state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = false; // 0.14
state.turnUserWantsGenerate = userAsksGenerate(text);
}
if (!isMachineTurn(opts) && !opts.skipSlash) {
@@ -8444,7 +8570,7 @@ if (!(meta && meta.historical)) {
patch.loras = state.lastPatch.loras;
}
appendSystemNote(`Ставлю ${aspect} и Generate (тот же промпт) — без повторной критики.`);
await applyQuickPatch(patch, `Aspect ${aspect}`);
await applyQuickPatch(patch, `Aspect ${aspect}`, { generate: true });
return;
}
}
@@ -8657,7 +8783,7 @@ if (!(meta && meta.historical)) {
state.lastContextChars = 0;
}
updateCtxChip();
const prose = extractPatch(reply).prose || reply;
const prose = visibleAssistantProse(reply);
state.history.push({ role: 'assistant', content: prose, persona, pack });
persistHistory();
setBusyPhase(state.pendingSilentGen ? 'silent_gen' : 'thinking');
@@ -8666,6 +8792,7 @@ if (!(meta && meta.historical)) {
...opts,
userText: text,
userWantsGenerate: !!opts.userWantsGenerate
|| !!state.turnUserWantsGenerate
|| (!isMachineTurn(opts) && state.pendingSilentGen),
attachedSlotIds: visionSlots.map((s) => s.id),
});
@@ -8866,8 +8993,14 @@ if (!(meta && meta.historical)) {
closeGenLightbox();
return;
}
if ((e.key === 'Enter' || e.key === ' ') && state.boardTab === 'generate' && state.selectedGenResultId) {
const row = (state.genResults || []).find((r) => r.id === state.selectedGenResultId);
if ((e.key === 'Enter' || e.key === ' ') && state.boardTab === 'generate') {
let row = (state.genResults || []).find((r) => r.id === state.selectedGenResultId);
if (!row?.src) {
const slot = generateSlot();
if (slot?.src && (slot.id === state.selectedSlotId || !state.selectedGenResultId)) {
row = { id: slot.id || GEN_ID, src: slot.src };
}
}
if (row?.src) {
e.preventDefault();
openGenLightbox(row.id);
@@ -9031,6 +9164,7 @@ if (!(meta && meta.historical)) {
window.__swarmAssistentWired = true;
loadSettings();
setView(state.view || 'chat');
void window.SA?.training?.resumePolling?.();
updateGate();
ensureBoard();
setBoardTab(state.boardTab || 'generate', { persist: false });
@@ -9357,19 +9491,27 @@ if (!(meta && meta.historical)) {
const vary = btn.getAttribute('data-vary');
const profile = btn.getAttribute('data-krea-profile');
if (aspect) {
await applyQuickPatch({ aspect, actions: ['generate'] }, `Aspect ${aspect}`);
await applyQuickPatch({ aspect }, `Aspect ${aspect}`);
} else if (seed === 'lock') {
await applyQuickPatch({ lock_seed: true }, 'Seed locked');
} else if (seed === 'random') {
await applyQuickPatch({ seed: -1, actions: ['generate'] }, 'Seed random');
await applyQuickPatch({ seed: -1 }, 'Seed random');
} else if (vary) {
await applyQuickPatch({ vary: true, seed: -1, actions: ['generate'] }, 'Vary');
await applyQuickPatch({ vary: true, seed: -1 }, 'Vary', { generate: true });
} else if (profile === 'turbo') {
const p = state.kreaProfiles?.turbo || mergedGenerationDefaults('turbo');
await applyQuickPatch({ steps: p.steps ?? 8, cfg: p.cfg ?? 1, sigma_shift: p.sigma_shift ?? 1.15, actions: ['generate'] }, 'Turbo');
await applyQuickPatch({
steps: p.steps ?? 8,
cfg: p.cfg ?? 1,
sigma_shift: p.sigma_shift ?? 1.15,
}, 'Turbo');
} else if (profile === 'raw') {
const p = state.kreaProfiles?.raw || mergedGenerationDefaults('raw');
await applyQuickPatch({ steps: p.steps ?? 28, cfg: p.cfg ?? 4.5, sigma_shift: p.sigma_shift, actions: ['generate'] }, 'RAW');
await applyQuickPatch({
steps: p.steps ?? 28,
cfg: p.cfg ?? 4.5,
sigma_shift: p.sigma_shift,
}, 'RAW');
}
renderLoraChips();
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Aspect → pixel sizes aligned with SwarmUI (Side Length 1024).
* Ref sheet @ 512px from SwarmUI T2IParamInput.ResolutionAspectReferences;
* width/height = round(ref * (sideLen / 512), 16).
*/
export const SWARM_ASPECT_REF_512 = {
'1:1': [512, 512],
'4:3': [576, 448],
'3:2': [608, 416],
'8:5': [608, 384],
'16:9': [672, 384],
'21:9': [768, 320],
'2:3': [416, 608],
'5:8': [384, 608],
'9:16': [384, 672],
'9:21': [320, 768],
};
const ASPECT_ALIASES = {
'2.35:1': '21:9',
};
/** Krea 1K bucket; not in Swarm aspect dropdown (we keep 4:5 chip). */
const KREA_EXTRA = {
'4:5': [928, 1152],
};
function roundTo16(n) {
return Math.round(n / 16) * 16;
}
export function swarmSizeFromRef(aspectKey, sideLen = 1024) {
const key = ASPECT_ALIASES[aspectKey] || aspectKey;
if (KREA_EXTRA[key]) {
return [...KREA_EXTRA[key]];
}
const ref = SWARM_ASPECT_REF_512[key];
if (!ref) {
return null;
}
const scale = sideLen / 512;
return [roundTo16(ref[0] * scale), roundTo16(ref[1] * scale)];
}
export function buildDefaultAspectTable(sideLen = 1024) {
const table = {};
for (const key of Object.keys(SWARM_ASPECT_REF_512)) {
table[key] = swarmSizeFromRef(key, sideLen);
}
table['2.35:1'] = swarmSizeFromRef('21:9', sideLen);
for (const [key, size] of Object.entries(KREA_EXTRA)) {
table[key] = [...size];
}
return table;
}
export const DEFAULT_ASPECT_TABLE = buildDefaultAspectTable(1024);
export function normalizeAspectKey(raw, table = DEFAULT_ASPECT_TABLE) {
let s = String(raw ?? '').trim().toLowerCase().replace(/\s+/g, '');
if (!s) {
return null;
}
if (s === 'square') {
s = '1:1';
} else if (s === 'portrait' || s === 'vert') {
s = '2:3';
} else if (s === 'landscape' || s === 'horiz') {
s = '16:9';
} else if (s === 'cinematic' || s === 'ultrawide') {
s = '2.35:1';
}
return table[s] ? s : null;
}
export function guessAspectFromDimensions(w, h, table = DEFAULT_ASPECT_TABLE) {
const width = parseInt(w, 10);
const height = parseInt(h, 10);
if (!width || !height) {
return null;
}
let best = null;
let bestDist = Infinity;
for (const [key, [aw, ah]] of Object.entries(table)) {
const dist = Math.abs(width / height - aw / ah)
+ Math.abs(width - aw) / 4000
+ Math.abs(height - ah) / 4000;
if (dist < bestDist) {
bestDist = dist;
best = key;
}
}
return bestDist < 0.12 ? best : null;
}
export function applyAspectTableFromObject(obj) {
if (!obj || typeof obj !== 'object') {
return false;
}
const next = {};
for (const [k, v] of Object.entries(obj)) {
if (Array.isArray(v) && v.length >= 2) {
next[k] = [Number(v[0]), Number(v[1])];
}
}
return Object.keys(next).length ? next : false;
}
+12 -7
View File
@@ -1,4 +1,4 @@
/** Turn intent — veto + model generate; user «сгенерируй» also counts when a prompt/delta exists. */
/** Turn intent — user-owned Generate; model generate:true is advisory only. */
export function cyrTokenRe(alts) {
const boundary = '(^|[^0-9A-Za-z_А-Яа-яЁё])';
@@ -32,8 +32,9 @@ export function userAsksGenerate(text) {
return true;
}
return cyrTokenRe(
'сгенер[а-яё]*|нарисуй|нарисуйте|'
+ 'запусти\\s+генер[а-яё]*|сделай\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
'сгенер[а-яё]*|нарисуй|нарисуйте|нарисуем|'
+ 'запусти\\s+генер[а-яё]*|'
+ 'сдела(й|ем|йте)\\s+(кадр|картинк[а-яё]*|изображ[а-яё]*)',
).test(t);
}
@@ -84,18 +85,22 @@ function generateFlagOn(patch) {
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
}
/** Model generate flag, or user «сгенерируй» when a prompt/delta already exists. */
/**
* Generate is user-owned. Model `generate:true` is advisory (Qwen dumps it on Q&A).
* User «нарисуй»/«сгенерируй» (or opts.userWantsGenerate from hops/buttons) starts Generate.
*/
export function resolveTurnIntent(patch, userText, opts = {}) {
const vetoed = !opts.machineTurn && userAsksNoGenerate(userText);
const modelAsked = generateFlagOn(patch);
const hasPrompt = !!(String(patch?.prompt || '').trim() || String(opts.sessionPrompt || '').trim());
const userAsked = !opts.machineTurn && userAsksGenerate(userText)
&& !!(modelAsked || String(patch?.prompt || '').trim());
const generate = !vetoed && !opts.fromAutoCritique && (modelAsked || userAsked);
&& (modelAsked || hasPrompt);
const generate = !vetoed && !opts.fromAutoCritique && (userAsked || !!opts.userWantsGenerate);
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] : []);
return { generate, look, vetoed, ask };
return { generate, look, vetoed, ask, modelAsked };
}
+4
View File
@@ -1,3 +1,4 @@
import { userAsksGenerate, userAsksNoGenerate, resolveTurnIntent } from './intent.js';
import { attachApi } from './api.js';
import { attachPatch, setPatchKeys } from './patch.js';
import { attachPersist } from './persist.js';
@@ -7,6 +8,9 @@ import { attachActivity } from './activity.js';
import { attachKreaProfile } from './kreaProfile.js';
window.SA = window.SA || {};
window.SA.userAsksGenerate = userAsksGenerate;
window.SA.userAsksNoGenerate = userAsksNoGenerate;
window.SA.resolveTurnIntent = resolveTurnIntent;
attachApi(window.SA);
attachPatch(window.SA);
attachPersist(window.SA);
+10
View File
@@ -131,6 +131,15 @@ export function extractPatch(text) {
return { prose: text, patch: null };
}
/** Chat body text: never fall back to the raw fence when a patch was extracted. */
export function visibleProse(text) {
const { prose, patch } = extractPatch(text);
if (patch) {
return prose || '';
}
return String(text || '');
}
export function isTerminalStreamPatch(obj) {
if (!obj || typeof obj !== 'object') {
return false;
@@ -170,4 +179,5 @@ export function attachPatch(SA) {
SA.normalizePatch = normalizePatch;
SA.extractPatch = extractPatch;
SA.generateFlagOn = generateFlagOn;
SA.visibleProse = visibleProse;
}
+11 -5
View File
@@ -345,18 +345,24 @@ export function fullSettingsDump(session, extras = {}) {
};
}
/** Model generate/actions, or askGenerateFn when a prompt/delta exists. vetoFn / fromAutoCritique cancel. */
export function resolveTurnIntent(patch, userText, { vetoFn, askGenerateFn, fromAutoCritique } = {}) {
/**
* 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 || String(delta.prompt || '').trim());
const generate = !vetoed && !fromAutoCritique && (modelAsked || userAsked);
&& (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 };
return { generate, look, vetoed, ask, modelAsked };
}
/** Params the client fills from Exact on Generate when the LLM omits them (sparse contract). */
+98 -8
View File
@@ -101,6 +101,7 @@ export function attachTraining(SA) {
if (state.ttab === 'train') {
syncModelfileModels();
syncQloraModels();
void resumeTrainJobPolling();
}
if (state.ttab === 'models') refreshTrainModels();
}
@@ -112,7 +113,12 @@ export function attachTraining(SA) {
const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 });
state.samples = data?.samples || [];
const stats = $('sa_train_stats');
if (stats) stats.textContent = `Одобрено: ${data?.approved ?? '—'} · всего: ${data?.total ?? '—'}`;
if (stats) {
const appr = data?.approved ?? '—';
const draft = data?.draft ?? '—';
const total = data?.total ?? '—';
stats.textContent = `Одобрено: ${appr} · черновики: ${draft} · всего: ${total}`;
}
const personaSel = $('sa_train_filter_persona');
if (personaSel && $('sa_persona')) {
const cur = personaSel.value || 'all';
@@ -134,8 +140,14 @@ export function attachTraining(SA) {
function renderSamples() {
const root = $('sa_train_samples');
if (!root) return;
const filter = $('sa_train_filter_status')?.value || 'all';
if (!state.samples.length) {
root.innerHTML = '<div class="sa-mem-empty">Нет примеров. Отметь ответы в чате или импортируй датасет.</div>';
const hint = filter === 'approved'
? 'Под фильтром «Одобренные» пусто. HF-импорт создаёт <strong>черновики</strong> — переключи на «Черновики» или «Все статусы».'
: filter === 'draft'
? 'Нет черновиков. Импортируй HF или отметь примеры в чате.'
: 'Нет примеров. Отметь ответы в чате или импортируй датасет (HF / файл).';
root.innerHTML = `<div class="sa-mem-empty">${hint}</div>`;
return;
}
root.innerHTML = '';
@@ -302,19 +314,63 @@ export function attachTraining(SA) {
}
async function importHf() {
const link = ($('sa_hf_link')?.value || '').trim();
if (!state.hfCheck?.id && link) {
await checkHfLink();
}
if (!state.hfSelected && !state.hfCheck?.id) {
setTrainStatus('Сначала проверь набор');
const hfSt = $('sa_hf_status');
if (hfSt) hfSt.textContent = 'Вставь ссылку и нажми «Проверить»';
return;
}
const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200;
const mapping = buildHfMappingPayload();
const btn = $('sa_btn_hf_import');
const hfSt = $('sa_hf_status');
const busy = limit > 400
? `Импортирую до ${limit} строк… (12 мин)`
: `Импортирую до ${limit}`;
setTrainStatus(busy);
if (hfSt) {
hfSt.textContent = busy;
hfSt.classList.add('sa-hf-busy');
}
if (btn) {
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = '…';
}
try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping });
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
const n = Number(data?.imported) || 0;
let msg;
if (data?.runner_only) {
msg = `Runner-only: ${data.note || id} (в sqlite не импортировали)`;
} else if (n > 0) {
const filt = $('sa_train_filter_status');
if (filt && filt.value === 'approved') {
filt.value = 'draft';
}
msg = `Импортировано: ${n} черновик(ов) — список ниже (фильтр → черновики)`;
} else {
msg = 'Импортировано: 0 — HF token в User Settings, маппинг или gated-набор';
}
setTrainStatus(msg);
if (hfSt) hfSt.textContent = msg;
await refreshSamples();
$('sa_train_samples')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
setTrainStatus(String(e.message || e));
const err = String(e.message || e);
setTrainStatus(err);
if (hfSt) hfSt.textContent = err;
} finally {
if (hfSt) hfSt.classList.remove('sa-hf-busy');
if (btn) {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Импортировать';
}
}
}
@@ -345,7 +401,9 @@ export function attachTraining(SA) {
}
async function createModelfile() {
setTrainStatus('Создаю модель…');
const btn = $('sa_btn_modelfile_create');
btn?.setAttribute('disabled', 'disabled');
setTrainStatus('Создаю Modelfile в Ollama…');
try {
const data = await SA.request('AssistentCreateOllamaModel', {
base_url: $('sa_base_url')?.value,
@@ -361,12 +419,16 @@ export function attachTraining(SA) {
SA.app?.refreshModels?.();
} catch (e) {
setTrainStatus(String(e.message || e));
} finally {
btn?.removeAttribute('disabled');
}
}
function setTrainMode(mode) {
$('sa_train_form_modelfile').hidden = mode !== 'modelfile';
$('sa_train_form_qlora').hidden = mode !== 'qlora';
const radio = document.querySelector(`input[name="sa_train_mode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
function setTrainingLock(on, text) {
@@ -387,15 +449,26 @@ export function attachTraining(SA) {
const prog = data?.progress || (data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null);
const active = data?.training_active || data?.job?.status === 'running';
const status = data?.job?.status || prog?.status;
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…');
const pct = prog?.percent;
const bannerText = active && pct != null
? `QLoRA · ${pct}%`
: active
? 'Идёт QLoRA…'
: 'Идёт тренировка…';
setTrainingLock(active, bannerText);
const logEl = $('sa_train_log');
const bar = $('sa_train_progress_fill');
const box = $('sa_train_progress');
if (prog) {
if (box) box.hidden = false;
if (bar && prog.percent != null) bar.style.width = `${prog.percent}%`;
if (bar && pct != null) bar.style.width = `${pct}%`;
if (logEl && prog.log) logEl.textContent = prog.log;
}
if (active) {
setTrainStatus(pct != null
? `QLoRA · ${pct}% — полный лог на вкладке «Тренировка»`
: 'QLoRA запущена — полный лог на вкладке «Тренировка»');
}
if (!active) {
clearInterval(state.polling);
state.polling = null;
@@ -422,6 +495,19 @@ export function attachTraining(SA) {
} catch (e) { /* ignore */ }
}
async function resumeTrainJobPolling() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const active = data?.training_active || data?.job?.status === 'running';
await pollTrainJob();
if (!active) return;
setTrainMode('qlora');
$('sa_btn_qlora_cancel').hidden = false;
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
} catch (e) { /* ignore */ }
}
async function startQlora() {
setTrainStatus('Запуск…');
try {
@@ -448,7 +534,9 @@ export function attachTraining(SA) {
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
pollTrainJob();
setTrainStatus('Тренировка запущена');
setTrainMode('qlora');
setTrainingTab('train');
setTrainStatus('QLoRA запущена — прогресс ниже');
} catch (e) {
setTrainStatus(String(e.message || e));
}
@@ -614,6 +702,7 @@ export function attachTraining(SA) {
$('sa_btn_save_runner')?.addEventListener('click', saveRunner);
loadRunner();
setTrainMode('modelfile');
void resumeTrainJobPolling();
}
SA.training = {
@@ -621,6 +710,7 @@ export function attachTraining(SA) {
wireTraining();
setTrainingTab(state.ttab);
},
resumePolling: resumeTrainJobPolling,
async curateFromChat(messages, meta) {
try {
await SA.request('AssistentUpsertTrainSample', {