Ship Assistent 0.15.11: collaborative params, richer UI, and strict image critique.
Adds session_exact pinning, sampler/scheduler chips, expanded param tags with non-default highlighting, QLoRA HF presets, LoRA strength editing, SQLite bootstrap for training memory, last-job UI, and harsher critique_image QC so result review leads with defects instead of praise. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+440
-87
@@ -969,7 +969,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
if (parseAspectFromUserText(t)) {
|
||||
return true;
|
||||
}
|
||||
if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw)\b/i.test(t)) {
|
||||
if (/\b(steps?|cfg|seed|sigma|aspect|resolution|batch|turbo|raw|sampler|scheduler|семплер|шедулер)\b/i.test(t)) {
|
||||
return true;
|
||||
}
|
||||
return cyrTokenRe(
|
||||
@@ -1302,49 +1302,198 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
badge.classList.toggle('sa-mode-hot', pack === 'critique_image' || pack === 'inpaint_edit');
|
||||
}
|
||||
|
||||
function formatLiveParamsLine() {
|
||||
function valuesMatchParam(a, b, key) {
|
||||
if (a == null && b == null) {
|
||||
return true;
|
||||
}
|
||||
if (a == null || b == null) {
|
||||
return false;
|
||||
}
|
||||
const numericKeys = new Set([
|
||||
'steps', 'cfg', 'sigma_shift', 'seed', 'batch', 'images', 'width', 'height', 'init_creativity',
|
||||
]);
|
||||
if (numericKeys.has(key)) {
|
||||
const na = parseFloat(String(a).replace(',', '.'));
|
||||
const nb = parseFloat(String(b).replace(',', '.'));
|
||||
if (Number.isFinite(na) && Number.isFinite(nb)) {
|
||||
if (key === 'cfg' || key === 'sigma_shift' || key === 'init_creativity') {
|
||||
return Math.abs(na - nb) < 0.011;
|
||||
}
|
||||
return na === nb;
|
||||
}
|
||||
}
|
||||
return String(a).toLowerCase() === String(b).toLowerCase();
|
||||
}
|
||||
|
||||
function canonicalParamDefaults(profileName) {
|
||||
const profile = profileName || detectKreaProfileName();
|
||||
const profDefs = exactProfileDefaults(profile);
|
||||
const gen = state.exact?.generation || {};
|
||||
const defaultAspect = gen.aspect || '1:1';
|
||||
const defaultSize = sizeFromAspect(defaultAspect) || ASPECT_TABLE['1:1'] || [1024, 1024];
|
||||
const batchDefault = gen.images ?? gen.batch ?? 1;
|
||||
return {
|
||||
profile,
|
||||
aspect: defaultAspect,
|
||||
width: defaultSize[0],
|
||||
height: defaultSize[1],
|
||||
steps: profDefs.steps,
|
||||
cfg: profDefs.cfg,
|
||||
sigma_shift: profDefs.sigma_shift,
|
||||
seed: '-1',
|
||||
batch: batchDefault,
|
||||
images: batchDefault,
|
||||
sampler: exactDefaultFor('sampler', profile),
|
||||
scheduler: exactDefaultFor('scheduler', profile),
|
||||
};
|
||||
}
|
||||
|
||||
function detectAppliedProfileName() {
|
||||
const { profiles } = resolveExactBundle();
|
||||
const stepsLive = val('input_steps');
|
||||
const cfgLive = val('input_cfgscale') || val('input_cfg');
|
||||
for (const name of ['turbo', 'raw']) {
|
||||
const d = profiles[name];
|
||||
if (!d) {
|
||||
continue;
|
||||
}
|
||||
if (valuesMatchParam(stepsLive, d.steps, 'steps') && valuesMatchParam(cfgLive, d.cfg, 'cfg')) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paramDisplayTag(label, displayValue, isCustom, defaultHint) {
|
||||
const cls = isCustom ? 'sa-param-tag sa-param-custom' : 'sa-param-tag';
|
||||
const hint = isCustom && defaultHint != null
|
||||
? ` title="Exact: ${defaultHint}"`
|
||||
: '';
|
||||
return `<span class="${cls}"${hint}>${escapeHtml(label)} ${escapeHtml(String(displayValue))}</span>`;
|
||||
}
|
||||
|
||||
function buildLiveParamsHtml() {
|
||||
const ckptProfile = detectKreaProfileName();
|
||||
const defs = canonicalParamDefaults(ckptProfile);
|
||||
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();
|
||||
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}`,
|
||||
const stepsRaw = val('input_steps');
|
||||
const cfgRaw = val('input_cfgscale') || val('input_cfg');
|
||||
const sigmaRaw = val('input_sigmashift');
|
||||
const seedRaw = val('input_seed') || '-1';
|
||||
const batchRaw = val('input_images') || val('input_batchsize') || '1';
|
||||
const samplerRaw = val('input_sampler') || defs.sampler || 'euler';
|
||||
const schedulerRaw = val('input_scheduler') || defs.scheduler || 'normal';
|
||||
const appliedProfile = detectAppliedProfileName() || ckptProfile;
|
||||
|
||||
const tags = [
|
||||
paramDisplayTag(
|
||||
'aspect',
|
||||
aspect,
|
||||
aspect !== '—' && !valuesMatchParam(aspect, defs.aspect, 'aspect'),
|
||||
defs.aspect,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'size',
|
||||
`${w || '?'}×${h || '?'}`,
|
||||
!!(w && h && (!valuesMatchParam(w, defs.width, 'width') || !valuesMatchParam(h, defs.height, 'height'))),
|
||||
`${defs.width}×${defs.height}`,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'profile',
|
||||
appliedProfile,
|
||||
appliedProfile !== ckptProfile,
|
||||
ckptProfile,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'steps',
|
||||
stepsRaw || '—',
|
||||
!!(stepsRaw && !valuesMatchParam(stepsRaw, defs.steps, 'steps')),
|
||||
defs.steps,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'cfg',
|
||||
cfgRaw || '—',
|
||||
!!(cfgRaw && !valuesMatchParam(cfgRaw, defs.cfg, 'cfg')),
|
||||
defs.cfg,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'σ',
|
||||
sigmaRaw || (defs.sigma_shift != null ? defs.sigma_shift : '—'),
|
||||
!!(sigmaRaw && defs.sigma_shift != null && !valuesMatchParam(sigmaRaw, defs.sigma_shift, 'sigma_shift')),
|
||||
defs.sigma_shift,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'seed',
|
||||
seedRaw === '-1' || seedRaw === '' ? 'rand' : seedRaw,
|
||||
!valuesMatchParam(seedRaw, defs.seed, 'seed'),
|
||||
'rand (−1)',
|
||||
),
|
||||
paramDisplayTag(
|
||||
'batch',
|
||||
batchRaw,
|
||||
!valuesMatchParam(batchRaw, defs.batch, 'batch'),
|
||||
defs.batch,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'sampler',
|
||||
samplerRaw,
|
||||
!valuesMatchParam(samplerRaw, defs.sampler, 'sampler'),
|
||||
defs.sampler,
|
||||
),
|
||||
paramDisplayTag(
|
||||
'scheduler',
|
||||
schedulerRaw,
|
||||
!valuesMatchParam(schedulerRaw, defs.scheduler, 'scheduler'),
|
||||
defs.scheduler,
|
||||
),
|
||||
];
|
||||
if (sigma) {
|
||||
parts.push(`σ ${sigma}`);
|
||||
|
||||
let initCtx = {};
|
||||
try {
|
||||
initCtx = readInitContext();
|
||||
} catch (e) { /* ignore */ }
|
||||
if (initCtx.has_init_image) {
|
||||
const cr = initCtx.init_creativity;
|
||||
const crStr = cr != null ? `@${cr}` : '';
|
||||
tags.push(paramDisplayTag('init', `on${crStr}`, true, 'off'));
|
||||
}
|
||||
parts.push(profile, `seed ${seed}`);
|
||||
if (batch && batch !== '1') {
|
||||
parts.push(`×${batch}`);
|
||||
if (initCtx.has_mask_image) {
|
||||
tags.push(paramDisplayTag('mask', 'on', true, 'off'));
|
||||
}
|
||||
if (sampler) {
|
||||
parts.push(sampler);
|
||||
|
||||
const negLive = liveNegativePrompt();
|
||||
const negDef = exactDefaultNegative();
|
||||
if (negLive && negDef && negLive !== negDef) {
|
||||
tags.push(paramDisplayTag('neg', '±', true, 'Exact default'));
|
||||
} else if (negLive && !negDef) {
|
||||
tags.push(paramDisplayTag('neg', 'on', true, 'empty'));
|
||||
}
|
||||
if (scheduler) {
|
||||
parts.push(scheduler);
|
||||
|
||||
const picCount = typeof countPromptImages === 'function' ? countPromptImages() : 0;
|
||||
if (picCount > 0) {
|
||||
tags.push(paramDisplayTag('prompt img', picCount, true, '0'));
|
||||
}
|
||||
return parts.join(' · ');
|
||||
|
||||
return tags.join('');
|
||||
}
|
||||
|
||||
function formatLiveParamsLine() {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = buildLiveParamsHtml();
|
||||
return el.textContent || '—';
|
||||
}
|
||||
|
||||
function syncLiveParamsBar() {
|
||||
const line = formatLiveParamsLine();
|
||||
const html = buildLiveParamsHtml();
|
||||
const boardEl = $('sa_live_params');
|
||||
const composerEl = $('sa_composer_params');
|
||||
if (boardEl) {
|
||||
boardEl.textContent = line;
|
||||
boardEl.innerHTML = html;
|
||||
}
|
||||
if (composerEl) {
|
||||
composerEl.textContent = line;
|
||||
composerEl.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1488,6 +1637,12 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
|
||||
function exactDefaultFor(key, profileName) {
|
||||
if (key === 'sampler') {
|
||||
return state.exact?.sampling?.defaults?.sampler ?? 'euler';
|
||||
}
|
||||
if (key === 'scheduler') {
|
||||
return state.exact?.sampling?.defaults?.scheduler ?? 'normal';
|
||||
}
|
||||
const { exact, profiles } = resolveExactBundle();
|
||||
const profile = profileName || exact.generation?.profile || detectKreaProfileName();
|
||||
const fromProfile = profiles[profile]?.[key];
|
||||
@@ -1512,6 +1667,21 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
}
|
||||
|
||||
function pinUserSessionParams(patch) {
|
||||
if (!patch || typeof patch !== 'object') {
|
||||
return;
|
||||
}
|
||||
const partial = {};
|
||||
for (const k of QUICK_PARAM_KEYS) {
|
||||
if (patch[k] != null) {
|
||||
partial[k] = patch[k];
|
||||
}
|
||||
}
|
||||
if (Object.keys(partial).length) {
|
||||
rememberSessionExact(partial);
|
||||
}
|
||||
}
|
||||
|
||||
/** Remember applied params when they differ from Exact (or the user asked). */
|
||||
function shouldRememberSessionParam(key, value) {
|
||||
if (state.restoringChat || value == null) {
|
||||
@@ -1559,6 +1729,11 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
state.chatSession.gen[key] = next[key];
|
||||
}
|
||||
}
|
||||
for (const key of S.SESSION_PINNED_PARAM_KEYS || ['sampler', 'scheduler', 'aspect', 'seed']) {
|
||||
if (next[key] != null) {
|
||||
state.chatSession.gen[key] = next[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -2516,6 +2691,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
return;
|
||||
}
|
||||
startBusyUi('silent_gen');
|
||||
pullLiveIntoSession();
|
||||
const S = window.SA && window.SA.session;
|
||||
if (S) {
|
||||
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), patch);
|
||||
@@ -2543,6 +2719,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
const S = window.SA && window.SA.session;
|
||||
if (state.lastPatch && S) {
|
||||
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), state.lastPatch);
|
||||
if (state.lastPatch.loras == null && state.chatSession?.gen) {
|
||||
state.chatSession.gen.loras = readLiveGenFields().loras || [];
|
||||
}
|
||||
}
|
||||
if (typeof startBusyUi === 'function') startBusyUi(state.lastPatch ? 'silent_gen' : 'generating');
|
||||
setStatus(state.lastPatch ? 'Сессия → Generate…' : 'Generate с текущей сессией…');
|
||||
@@ -4156,9 +4335,15 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
steps: exactDefs.steps,
|
||||
cfg: exactDefs.cfg,
|
||||
sigma_shift: exactDefs.sigma_shift,
|
||||
sampler: val('input_sampler') || state.exact?.sampling?.defaults?.sampler || 'euler',
|
||||
scheduler: val('input_scheduler') || state.exact?.sampling?.defaults?.scheduler || 'normal',
|
||||
},
|
||||
sampling_options: state.exact?.sampling || null,
|
||||
...initCtx,
|
||||
};
|
||||
if (state.sessionExact && Object.keys(state.sessionExact).length) {
|
||||
extra.session_exact = { ...state.sessionExact };
|
||||
}
|
||||
if (S && typeof S.compactContext === 'function') {
|
||||
const ctx = S.compactContext(state.chatSession, {
|
||||
architecture_ok: typeof isKreaSelected === 'function' ? isKreaSelected() : true,
|
||||
@@ -4673,18 +4858,22 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
} else if (patch.sigma_shift == null && isEmptyParamField(val('input_sigmashift')) && defaults.sigma_shift != null) {
|
||||
setVal('input_sigmashift', String(defaults.sigma_shift));
|
||||
}
|
||||
if (patch.sampler != null) {
|
||||
if (document.getElementById('input_sampler')) {
|
||||
setVal('input_sampler', String(patch.sampler));
|
||||
}
|
||||
if (shouldRememberSessionParam('sampler', patch.sampler)) {
|
||||
rememberSessionExact({ sampler: patch.sampler });
|
||||
if (patch.scheduler != null && document.getElementById('input_scheduler')) {
|
||||
if (!shouldSkipSessionRollback('scheduler', patch.scheduler)) {
|
||||
setVal('input_scheduler', String(patch.scheduler));
|
||||
if (shouldRememberSessionParam('scheduler', patch.scheduler)) {
|
||||
rememberSessionExact({ scheduler: patch.scheduler });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patch.scheduler != null && document.getElementById('input_scheduler')) {
|
||||
setVal('input_scheduler', String(patch.scheduler));
|
||||
if (shouldRememberSessionParam('scheduler', patch.scheduler)) {
|
||||
rememberSessionExact({ scheduler: patch.scheduler });
|
||||
if (patch.sampler != null) {
|
||||
if (!shouldSkipSessionRollback('sampler', patch.sampler)) {
|
||||
if (document.getElementById('input_sampler')) {
|
||||
setVal('input_sampler', String(patch.sampler));
|
||||
}
|
||||
if (shouldRememberSessionParam('sampler', patch.sampler)) {
|
||||
rememberSessionExact({ sampler: patch.sampler });
|
||||
}
|
||||
}
|
||||
}
|
||||
const batch = patch.images != null ? patch.images : patch.batch;
|
||||
@@ -5210,6 +5399,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
|
||||
// 0.14: session is source of truth for Generate
|
||||
if (typeof pullLiveIntoSession === 'function') pullLiveIntoSession();
|
||||
if (state.chatSession?.gen) {
|
||||
state.chatSession.gen.loras = readLiveGenFields().loras || [];
|
||||
}
|
||||
|
||||
// Live UI often still holds SD-like leftovers (steps 20 / cfg 7). Drop those
|
||||
// Exact keys from the session unless this delta or the user explicitly set them.
|
||||
@@ -5456,7 +5648,10 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
setPackValue('critique_image', { flash: true });
|
||||
if ($('sa_input')) {
|
||||
$('sa_input').value = 'Critique this result and improve the prompt for the next generation.';
|
||||
$('sa_input').value =
|
||||
'Strict QC of this Generate frame vs the live prompt: list concrete defects and artifacts first, '
|
||||
+ 'then one fenced JSON patch with an improved prompt and any param/LoRA fixes. '
|
||||
+ 'No empty praise.';
|
||||
}
|
||||
const gen = generateSlot();
|
||||
if (gen) {
|
||||
@@ -5489,7 +5684,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
setPackValue('critique_image', { flash: true });
|
||||
if ($('sa_input')) {
|
||||
$('sa_input').value = 'Look at the Generate result and briefly say what worked and what to fix next.';
|
||||
$('sa_input').value =
|
||||
'Strict review of the Generate frame vs the prompt: what failed, what artifacts you see, '
|
||||
+ 'what to change in prompt and params next. Skip hollow compliments.';
|
||||
}
|
||||
setStatus('Auto look_at…');
|
||||
await sendChat({ fromVisionHop: true, forceSlotIds: [GEN_ID], skipAutoPack: true });
|
||||
@@ -5525,8 +5722,8 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
setPackValue('critique_image', { flash: true });
|
||||
if ($('sa_input')) {
|
||||
$('sa_input').value = label
|
||||
? `Посмотри результат «${label}»: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.`
|
||||
: 'Посмотри результат: что получилось, что сломалось, и как поправить промпт и параметры для следующего кадра.';
|
||||
? `Критически разбери «${label}»: что не сходится с промптом, артефакты, композиция/свет — и как поправить prompt и params для следующего кадра. Без общих похвал.`
|
||||
: 'Критически разбери результат: что не сходится с промптом, артефакты, композиция/свет — и как поправить prompt и params для следующего кадра. Без общих похвал.';
|
||||
}
|
||||
await sendChat({ forceSlotIds: [GEN_ID], skipAutoPack: true });
|
||||
}
|
||||
@@ -6204,6 +6401,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
? { ...state.config.control_values }
|
||||
: null;
|
||||
state.config = data;
|
||||
if (window.SA?.training?.onConfig) {
|
||||
window.SA.training.onConfig(data);
|
||||
}
|
||||
if (window.SA?.applyConfigPatchKeys) {
|
||||
window.SA.applyConfigPatchKeys(data);
|
||||
}
|
||||
@@ -6679,6 +6879,10 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
btn.setAttribute('data-vary', value || '1');
|
||||
} else if (action === 'krea_profile') {
|
||||
btn.setAttribute('data-krea-profile', value);
|
||||
} else if (action === 'sampler') {
|
||||
btn.setAttribute('data-sampler', value);
|
||||
} else if (action === 'scheduler') {
|
||||
btn.setAttribute('data-scheduler', value);
|
||||
}
|
||||
box.appendChild(btn);
|
||||
}
|
||||
@@ -7727,41 +7931,66 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
return base.replace(/\.safetensors$/i, '').slice(0, 28);
|
||||
}
|
||||
|
||||
function readSelectedLoras() {
|
||||
try {
|
||||
if (typeof loraHelper !== 'undefined' && loraHelper && Array.isArray(loraHelper.selected)) {
|
||||
return loraHelper.selected.map((l) => ({
|
||||
name: l.name || l,
|
||||
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
|
||||
}));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
function syncLorasToSession(loras) {
|
||||
const S = window.SA && window.SA.session;
|
||||
if (!S) {
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(loras) ? loras : readSelectedLoras();
|
||||
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), { loras: list });
|
||||
}
|
||||
|
||||
async function applyLorasList(loras) {
|
||||
await applyPatch({ loras }, 'loras');
|
||||
syncLorasToSession(loras);
|
||||
renderLoraChips();
|
||||
}
|
||||
|
||||
function renderLoraChips() {
|
||||
const root = $('sa_lora_chips');
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
root.innerHTML = '';
|
||||
let selected = [];
|
||||
try {
|
||||
if (typeof loraHelper !== 'undefined' && Array.isArray(loraHelper?.selected)) {
|
||||
selected = loraHelper.selected.map((l) => ({
|
||||
name: l.name || l,
|
||||
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[l.name || l]) || 1,
|
||||
}));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
const selected = readSelectedLoras();
|
||||
for (const l of selected) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'sa-lora-chip';
|
||||
btn.title = `${l.name} ×${l.weight} — клик снять`;
|
||||
btn.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
|
||||
btn.addEventListener('click', () => {
|
||||
try {
|
||||
if (typeof loraHelper !== 'undefined' && typeof loraHelper.removeLora === 'function') {
|
||||
loraHelper.removeLora(l.name);
|
||||
} else if (loraHelper?.selected) {
|
||||
loraHelper.selected = loraHelper.selected.filter((x) => (x.name || x) !== l.name);
|
||||
if (typeof loraHelper.rebuildUI === 'function') {
|
||||
loraHelper.rebuildUI();
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
renderLoraChips();
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'sa-lora-chip';
|
||||
chip.title = l.name;
|
||||
const main = document.createElement('button');
|
||||
main.type = 'button';
|
||||
main.className = 'sa-lora-chip-main';
|
||||
main.textContent = `${shortLoraName(l.name)} ${Number(l.weight).toFixed(2)}`;
|
||||
main.title = `${l.name} — клик изменить силу`;
|
||||
main.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
openLoraWeightEditor(main, l.name, l.weight);
|
||||
});
|
||||
root.appendChild(btn);
|
||||
const rm = document.createElement('button');
|
||||
rm.type = 'button';
|
||||
rm.className = 'sa-lora-chip-rm';
|
||||
rm.textContent = '×';
|
||||
rm.title = 'Снять LoRA';
|
||||
rm.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const next = selected.filter((x) => x.name !== l.name);
|
||||
await applyLorasList(next);
|
||||
});
|
||||
chip.appendChild(main);
|
||||
chip.appendChild(rm);
|
||||
root.appendChild(chip);
|
||||
}
|
||||
const add = document.createElement('button');
|
||||
add.type = 'button';
|
||||
@@ -7801,23 +8030,17 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
btn.textContent = `${shortLoraName(name)}${l.krea_likely ? ' · krea' : ''}`;
|
||||
btn.title = name;
|
||||
btn.addEventListener('click', async () => {
|
||||
await applyPatch({
|
||||
loras: [
|
||||
...((() => {
|
||||
try {
|
||||
return (loraHelper?.selected || []).map((x) => ({
|
||||
name: x.name || x,
|
||||
weight: (loraHelper.loraWeightPref && loraHelper.loraWeightPref[x.name || x]) || 1,
|
||||
}));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
})()),
|
||||
{ name, weight: l.default_weight ? parseFloat(l.default_weight) : 0.8, triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []) },
|
||||
],
|
||||
}, 'loras');
|
||||
const cur = readSelectedLoras();
|
||||
const next = [
|
||||
...cur.filter((x) => x.name !== name),
|
||||
{
|
||||
name,
|
||||
weight: l.default_weight ? parseFloat(l.default_weight) : 0.8,
|
||||
triggers: l.triggers || (l.trigger_phrase ? [l.trigger_phrase] : []),
|
||||
},
|
||||
];
|
||||
await applyLorasList(next);
|
||||
picker.remove();
|
||||
renderLoraChips();
|
||||
});
|
||||
list.appendChild(btn);
|
||||
if (++n >= 40) {
|
||||
@@ -7843,6 +8066,71 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
filter.focus();
|
||||
}
|
||||
|
||||
function openLoraWeightEditor(anchor, name, weight) {
|
||||
document.querySelectorAll('.sa-lora-weight-pop').forEach((n) => n.remove());
|
||||
const pop = document.createElement('div');
|
||||
pop.className = 'sa-lora-weight-pop';
|
||||
const label = document.createElement('label');
|
||||
label.textContent = shortLoraName(name);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.min = '0';
|
||||
input.max = '2';
|
||||
input.step = '0.05';
|
||||
input.value = String(Number(weight) || 1);
|
||||
input.title = 'Сила LoRA (0–2)';
|
||||
label.appendChild(input);
|
||||
pop.appendChild(label);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'sa-lora-weight-actions';
|
||||
const ok = document.createElement('button');
|
||||
ok.type = 'button';
|
||||
ok.className = 'basic-button sa-primary';
|
||||
ok.textContent = 'OK';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'basic-button';
|
||||
cancel.textContent = 'Отмена';
|
||||
row.appendChild(ok);
|
||||
row.appendChild(cancel);
|
||||
pop.appendChild(row);
|
||||
const apply = async () => {
|
||||
const w = Math.min(2, Math.max(0, parseFloat(input.value) || 0));
|
||||
const next = readSelectedLoras().map((l) => (
|
||||
l.name === name ? { ...l, weight: w } : l
|
||||
));
|
||||
await applyLorasList(next);
|
||||
pop.remove();
|
||||
};
|
||||
ok.addEventListener('click', apply);
|
||||
cancel.addEventListener('click', () => pop.remove());
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
pop.remove();
|
||||
}
|
||||
});
|
||||
const composer = $('sa_composer') || document.body;
|
||||
composer.style.position = composer.style.position || 'relative';
|
||||
composer.appendChild(pop);
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const cRect = composer.getBoundingClientRect();
|
||||
pop.style.left = `${Math.max(4, rect.left - cRect.left)}px`;
|
||||
pop.style.top = `${rect.bottom - cRect.top + 4}px`;
|
||||
input.focus();
|
||||
input.select();
|
||||
const onDoc = (ev) => {
|
||||
if (!pop.contains(ev.target) && ev.target !== anchor) {
|
||||
pop.remove();
|
||||
document.removeEventListener('mousedown', onDoc, true);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', onDoc, true), 0);
|
||||
}
|
||||
|
||||
function inventoryIsStale(maxAgeMs = 20000) {
|
||||
if (!state.inventoryFetchedAt) {
|
||||
return true;
|
||||
@@ -8033,6 +8321,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
});
|
||||
}
|
||||
if (S && effective) state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), effective);
|
||||
if (effective?.loras == null && state.chatSession?.gen) {
|
||||
state.chatSession.gen.loras = readLiveGenFields().loras || [];
|
||||
}
|
||||
await pushSessionToSwarm(state.chatSession);
|
||||
if (typeof syncLiveParamsBar === 'function') syncLiveParamsBar();
|
||||
if (typeof appendSystemNote === 'function') {
|
||||
@@ -8105,6 +8396,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
}
|
||||
if (S) {
|
||||
state.chatSession = S.mergeDelta(state.chatSession || S.emptySession(), withActions);
|
||||
if (withActions.loras == null && state.chatSession?.gen) {
|
||||
state.chatSession.gen.loras = readLiveGenFields().loras || [];
|
||||
}
|
||||
}
|
||||
state._quietParamApply = (state._quietParamApply || 0) + 1;
|
||||
try {
|
||||
@@ -8118,6 +8412,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
} finally {
|
||||
state._quietParamApply = Math.max(0, (state._quietParamApply || 1) - 1);
|
||||
}
|
||||
pinUserSessionParams(withActions);
|
||||
state.lastUserParamIntent = prevIntent;
|
||||
setStatus(note || (wantGenerate ? 'Applied' : 'Параметры (без Generate)'));
|
||||
if (wantGenerate) {
|
||||
@@ -8132,15 +8427,44 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
if (!bar) {
|
||||
return;
|
||||
}
|
||||
const defs = canonicalParamDefaults();
|
||||
const ckptProfile = detectKreaProfileName();
|
||||
const cur = guessAspectFromSize(val('input_width'), val('input_height'));
|
||||
const seed = val('input_seed');
|
||||
bar.querySelectorAll('[data-aspect]').forEach((btn) => {
|
||||
btn.classList.toggle('sa-chip-active', btn.getAttribute('data-aspect') === cur);
|
||||
const aspectVal = btn.getAttribute('data-aspect');
|
||||
const active = aspectVal === cur;
|
||||
btn.classList.toggle('sa-chip-active', active);
|
||||
btn.classList.toggle('sa-chip-custom', active && !valuesMatchParam(aspectVal, defs.aspect, 'aspect'));
|
||||
});
|
||||
bar.querySelectorAll('[data-seed]').forEach((btn) => {
|
||||
const mode = btn.getAttribute('data-seed');
|
||||
const active = (mode === 'lock' && seed && seed !== '-1') || (mode === 'random' && (!seed || seed === '-1'));
|
||||
btn.classList.toggle('sa-chip-active', active);
|
||||
btn.classList.toggle('sa-chip-custom', mode === 'lock' && active);
|
||||
});
|
||||
bar.querySelectorAll('[data-krea-profile]').forEach((btn) => {
|
||||
const p = btn.getAttribute('data-krea-profile');
|
||||
const profDefs = exactProfileDefaults(p);
|
||||
const stepsMatch = valuesMatchParam(val('input_steps'), profDefs.steps, 'steps');
|
||||
const cfgMatch = valuesMatchParam(val('input_cfgscale') || val('input_cfg'), profDefs.cfg, 'cfg');
|
||||
const active = stepsMatch && cfgMatch;
|
||||
btn.classList.toggle('sa-chip-active', active);
|
||||
btn.classList.toggle('sa-chip-custom', active && p !== ckptProfile);
|
||||
});
|
||||
const curSampler = (val('input_sampler') || defs.sampler || '').toLowerCase();
|
||||
const curScheduler = (val('input_scheduler') || defs.scheduler || '').toLowerCase();
|
||||
bar.querySelectorAll('[data-sampler]').forEach((btn) => {
|
||||
const v = btn.getAttribute('data-sampler').toLowerCase();
|
||||
const active = v === curSampler;
|
||||
btn.classList.toggle('sa-chip-active', active);
|
||||
btn.classList.toggle('sa-chip-custom', active && !valuesMatchParam(v, defs.sampler, 'sampler'));
|
||||
});
|
||||
bar.querySelectorAll('[data-scheduler]').forEach((btn) => {
|
||||
const v = btn.getAttribute('data-scheduler').toLowerCase();
|
||||
const active = v === curScheduler;
|
||||
btn.classList.toggle('sa-chip-active', active);
|
||||
btn.classList.toggle('sa-chip-custom', active && !valuesMatchParam(v, defs.scheduler, 'scheduler'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8359,7 +8683,10 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
slot.attach = true;
|
||||
renderBoard();
|
||||
if ($('sa_input')) {
|
||||
$('sa_input').value = `Look at ${id} and describe what you see.`;
|
||||
const critical = id === GEN_ID || slot.type === 'generate';
|
||||
$('sa_input').value = critical
|
||||
? 'Strict QC of this Generate frame vs the live prompt: concrete defects first, then JSON patch with prompt/param fixes. No empty praise.'
|
||||
: `Critically review ${id} vs the intended scene: defects and how to fix prompt/params.`;
|
||||
}
|
||||
setPackValue('critique_image', { flash: true });
|
||||
await sendChat({ forceSlotIds: [id], skipAutoPack: true });
|
||||
@@ -8416,6 +8743,26 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
await applyQuickPatch({ vary: true, seed: -1 }, 'Vary (new seed)', { generate: true });
|
||||
return true;
|
||||
}
|
||||
if (cmd === 'sampler' || cmd === 'семплер') {
|
||||
const name = (arg || '').trim().toLowerCase();
|
||||
if (!name) {
|
||||
const opts = (state.exact?.sampling?.samplers || ['euler', 'heun', 'dpmpp_2m']).join(', ');
|
||||
setStatus(`Usage: /sampler ${opts}`);
|
||||
return true;
|
||||
}
|
||||
await applyQuickPatch({ sampler: name }, `Sampler ${name}`);
|
||||
return true;
|
||||
}
|
||||
if (cmd === 'scheduler' || cmd === 'шедулер') {
|
||||
const name = (arg || '').trim().toLowerCase();
|
||||
if (!name) {
|
||||
const opts = (state.exact?.sampling?.schedulers || ['normal', 'simple', 'karras']).join(', ');
|
||||
setStatus(`Usage: /scheduler ${opts}`);
|
||||
return true;
|
||||
}
|
||||
await applyQuickPatch({ scheduler: name }, `Scheduler ${name}`);
|
||||
return true;
|
||||
}
|
||||
if (cmd === 'inventory' || cmd === 'inv') {
|
||||
setStatus('Rescanning models…');
|
||||
triggerSwarmModelRefresh(async () => {
|
||||
@@ -9490,6 +9837,8 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
const seed = btn.getAttribute('data-seed');
|
||||
const vary = btn.getAttribute('data-vary');
|
||||
const profile = btn.getAttribute('data-krea-profile');
|
||||
const sampler = btn.getAttribute('data-sampler');
|
||||
const scheduler = btn.getAttribute('data-scheduler');
|
||||
if (aspect) {
|
||||
await applyQuickPatch({ aspect }, `Aspect ${aspect}`);
|
||||
} else if (seed === 'lock') {
|
||||
@@ -9512,6 +9861,10 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
|
||||
cfg: p.cfg ?? 4.5,
|
||||
sigma_shift: p.sigma_shift,
|
||||
}, 'RAW');
|
||||
} else if (sampler) {
|
||||
await applyQuickPatch({ sampler }, `Sampler ${sampler}`);
|
||||
} else if (scheduler) {
|
||||
await applyQuickPatch({ scheduler }, `Scheduler ${scheduler}`);
|
||||
}
|
||||
renderLoraChips();
|
||||
});
|
||||
|
||||
@@ -368,6 +368,9 @@ export function resolveTurnIntent(patch, userText, {
|
||||
/** 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'];
|
||||
|
||||
/** Params the user may pin via chips/UI; preserved on Generate when the LLM omits them. */
|
||||
export const SESSION_PINNED_PARAM_KEYS = ['sampler', 'scheduler', 'aspect', 'seed'];
|
||||
|
||||
/**
|
||||
* Turbo/RAW profile numbers from Exact — no sessionExact overlay.
|
||||
* profileName should already reflect the live checkpoint (turbo vs raw).
|
||||
@@ -431,6 +434,14 @@ export function mergeExactParamsForGenerate(patch, {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of SESSION_PINNED_PARAM_KEYS) {
|
||||
if (out[key] != null) {
|
||||
continue;
|
||||
}
|
||||
if (sessionExact?.[key] != null) {
|
||||
out[key] = sessionExact[key];
|
||||
}
|
||||
}
|
||||
return { patch: out, clearSessionKeys, profile: defaults.profile };
|
||||
}
|
||||
|
||||
@@ -450,6 +461,7 @@ export function attachSession(SA) {
|
||||
resolveExactProfileDefaults,
|
||||
mergeExactParamsForGenerate,
|
||||
EXACT_GENERATE_PARAM_KEYS,
|
||||
SESSION_PINNED_PARAM_KEYS,
|
||||
GEN_KEYS,
|
||||
};
|
||||
}
|
||||
|
||||
+202
-11
@@ -10,6 +10,8 @@ function escapeHtml(s) {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
const QLORA_HF_CUSTOM = '__custom__';
|
||||
|
||||
export function attachTraining(SA) {
|
||||
const state = {
|
||||
ttab: 'dataset',
|
||||
@@ -20,6 +22,7 @@ export function attachTraining(SA) {
|
||||
hfMapping: null,
|
||||
trainWs: null,
|
||||
polling: null,
|
||||
qloraTraining: null,
|
||||
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
|
||||
agentLinked: 0,
|
||||
};
|
||||
@@ -46,6 +49,8 @@ export function attachTraining(SA) {
|
||||
if ($('sa_agent_heard_quota')) $('sa_agent_heard_quota').value = String(state.agentSettings.heard_quota);
|
||||
setAgentHeardStats(state.agentLinked);
|
||||
} catch (e) {
|
||||
const msg = String(e.message || e);
|
||||
setTrainStatus(isSqliteError(msg) ? `${msg} — ${sqliteHint()}` : msg);
|
||||
console.warn('loadAgentHeardSettings', e);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +89,55 @@ export function attachTraining(SA) {
|
||||
if (el) el.textContent = msg || '';
|
||||
}
|
||||
|
||||
function isSqliteError(msg) {
|
||||
const s = String(msg || '').toLowerCase();
|
||||
return s.includes('sqlite') || s.includes('sqlconnection');
|
||||
}
|
||||
|
||||
function sqliteHint() {
|
||||
return 'База Assistent (SQLite) недоступна — gpu-rent seed-extensions + restart SwarmUI (≥0.15.6).';
|
||||
}
|
||||
|
||||
function parseJobProgress(job) {
|
||||
if (!job) return null;
|
||||
const raw = job.progress_json;
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLastTrainJob(job) {
|
||||
const box = $('sa_train_last_job');
|
||||
if (!box) return;
|
||||
if (!job) {
|
||||
box.hidden = true;
|
||||
box.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const prog = parseJobProgress(job);
|
||||
const status = job.status || prog?.status || '—';
|
||||
const out = job.output_name || prog?.ollama?.name || '—';
|
||||
const base = job.base_model || '—';
|
||||
const ollama = prog?.ollama;
|
||||
let ollamaLine = '';
|
||||
if (ollama?.success) {
|
||||
ollamaLine = `<div class="sa-train-last-ok">Ollama: <strong>${escapeHtml(ollama.name || out)}</strong> — выбери в шапке чата</div>`;
|
||||
} else if (ollama?.error) {
|
||||
ollamaLine = `<div class="sa-train-last-warn">Ollama: ${escapeHtml(ollama.error)}</div>`;
|
||||
} else if (ollama?.skipped) {
|
||||
ollamaLine = `<div class="sa-train-last-warn">${escapeHtml(ollama.note || 'Адаптер на диске, ollama create вручную')}</div>`;
|
||||
}
|
||||
box.hidden = false;
|
||||
box.innerHTML = `
|
||||
<div class="sa-train-last-head">Последняя тренировка · ${escapeHtml(job.kind || 'qlora')} · <span class="sa-hf-badge sa-hf-badge-${status === 'completed' ? 'ok' : status === 'failed' ? 'no' : 'map'}">${escapeHtml(status)}</span></div>
|
||||
<div>HF base: <code>${escapeHtml(base)}</code> → имя: <code>${escapeHtml(out)}</code></div>
|
||||
${ollamaLine}
|
||||
${prog?.log ? `<pre class="sa-train-log sa-train-last-log">${escapeHtml(String(prog.log).slice(-4000))}</pre>` : ''}`;
|
||||
}
|
||||
|
||||
function setTrainingTab(id) {
|
||||
state.ttab = id || 'dataset';
|
||||
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
|
||||
@@ -111,6 +165,15 @@ export function attachTraining(SA) {
|
||||
const status = $('sa_train_filter_status')?.value || 'all';
|
||||
const persona = $('sa_train_filter_persona')?.value || 'all';
|
||||
const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 });
|
||||
if (data?.error) {
|
||||
const msg = data.error;
|
||||
setTrainStatus(isSqliteError(msg) ? `${msg} — ${sqliteHint()}` : msg);
|
||||
const stats = $('sa_train_stats');
|
||||
if (stats) stats.textContent = 'Датасет недоступен (SQLite)';
|
||||
state.samples = [];
|
||||
renderSamples();
|
||||
return;
|
||||
}
|
||||
state.samples = data?.samples || [];
|
||||
const stats = $('sa_train_stats');
|
||||
if (stats) {
|
||||
@@ -233,8 +296,103 @@ export function attachTraining(SA) {
|
||||
return state.hfMapping;
|
||||
}
|
||||
|
||||
function getQloraHfBase() {
|
||||
const sel = $('sa_qlora_base');
|
||||
if (!sel) return '';
|
||||
if (sel.value === QLORA_HF_CUSTOM) {
|
||||
return ($('sa_qlora_base_custom')?.value || '').trim();
|
||||
}
|
||||
return (sel.value || '').trim();
|
||||
}
|
||||
|
||||
function applyQloraPresetFromSelect({ fillName = true } = {}) {
|
||||
const sel = $('sa_qlora_base');
|
||||
const customRow = $('sa_qlora_base_custom_row');
|
||||
if (!sel) return;
|
||||
if (sel.value === QLORA_HF_CUSTOM) {
|
||||
if (customRow) customRow.hidden = false;
|
||||
return;
|
||||
}
|
||||
if (customRow) customRow.hidden = true;
|
||||
const presetJson = sel.selectedOptions[0]?.dataset?.preset;
|
||||
if (!presetJson) return;
|
||||
let preset;
|
||||
try {
|
||||
preset = JSON.parse(presetJson);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const nameEl = $('sa_qlora_name');
|
||||
if (fillName && nameEl && !nameEl.value.trim() && preset.default_output) {
|
||||
nameEl.value = preset.default_output;
|
||||
}
|
||||
const ollamaSel = $('sa_qlora_ollama_base');
|
||||
if (ollamaSel && preset.ollama_hint) {
|
||||
const hint = preset.ollama_hint;
|
||||
if ([...ollamaSel.options].some((o) => o.value === hint)) {
|
||||
ollamaSel.value = hint;
|
||||
}
|
||||
}
|
||||
if (preset.rank != null && $('sa_qlora_rank')) {
|
||||
$('sa_qlora_rank').value = preset.rank;
|
||||
}
|
||||
if (preset.seq_len != null && $('sa_qlora_seq')) {
|
||||
$('sa_qlora_seq').value = preset.seq_len;
|
||||
}
|
||||
}
|
||||
|
||||
function populateQloraHfPresets(training) {
|
||||
const sel = $('sa_qlora_base');
|
||||
if (!sel) return;
|
||||
const prevBase = getQloraHfBase();
|
||||
const models = Array.isArray(training?.hf_models) ? training.hf_models : [];
|
||||
sel.innerHTML = '<option value="">— выберите модель —</option>';
|
||||
for (const m of models) {
|
||||
const hfId = (m.hf_id || m.id || '').trim();
|
||||
if (!hfId) continue;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = hfId;
|
||||
opt.textContent = m.title ? `${m.title} (${hfId})` : hfId;
|
||||
opt.dataset.preset = JSON.stringify(m);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
const customOpt = document.createElement('option');
|
||||
customOpt.value = QLORA_HF_CUSTOM;
|
||||
customOpt.textContent = 'Другая (ввести HF id…)';
|
||||
sel.appendChild(customOpt);
|
||||
if (prevBase && [...sel.options].some((o) => o.value === prevBase)) {
|
||||
sel.value = prevBase;
|
||||
} else if (prevBase) {
|
||||
sel.value = QLORA_HF_CUSTOM;
|
||||
const custom = $('sa_qlora_base_custom');
|
||||
if (custom) custom.value = prevBase;
|
||||
} else if (models.length) {
|
||||
sel.value = (models[0].hf_id || models[0].id || '').trim();
|
||||
}
|
||||
applyQloraPresetFromSelect({ fillName: !prevBase });
|
||||
}
|
||||
|
||||
async function ensureTrainingQloraConfig(force = false) {
|
||||
if (!force && state.qloraTraining) {
|
||||
return state.qloraTraining;
|
||||
}
|
||||
try {
|
||||
const persona = $('sa_persona')?.value || '';
|
||||
const data = await SA.request('AssistentGetConfig', { persona });
|
||||
state.qloraTraining = data?.training && typeof data.training === 'object'
|
||||
? data.training
|
||||
: { hf_models: [] };
|
||||
return state.qloraTraining;
|
||||
} catch {
|
||||
state.qloraTraining = state.qloraTraining || { hf_models: [] };
|
||||
return state.qloraTraining;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncQloraModels() {
|
||||
try {
|
||||
const training = await ensureTrainingQloraConfig();
|
||||
populateQloraHfPresets(training);
|
||||
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
|
||||
const data = await SA.request('AssistentListModels', { baseUrl });
|
||||
const models = data?.models || [];
|
||||
@@ -250,6 +408,7 @@ export function attachTraining(SA) {
|
||||
}
|
||||
if (cur) sel.value = cur;
|
||||
else if ($('sa_model')?.value) sel.value = $('sa_model').value;
|
||||
applyQloraPresetFromSelect({ fillName: false });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -363,8 +522,9 @@ export function attachTraining(SA) {
|
||||
$('sa_train_samples')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} catch (e) {
|
||||
const err = String(e.message || e);
|
||||
setTrainStatus(err);
|
||||
if (hfSt) hfSt.textContent = err;
|
||||
const show = isSqliteError(err) ? `${err} — ${sqliteHint()}` : err;
|
||||
setTrainStatus(show);
|
||||
if (hfSt) hfSt.textContent = show;
|
||||
} finally {
|
||||
if (hfSt) hfSt.classList.remove('sa-hf-busy');
|
||||
if (btn) {
|
||||
@@ -477,17 +637,19 @@ export function attachTraining(SA) {
|
||||
if (status === 'completed' || status === 'completed_with_warnings') {
|
||||
const ollama = prog?.ollama;
|
||||
if (ollama?.success) {
|
||||
setTrainStatus(`Готово: модель ${ollama.name} в Ollama`);
|
||||
setTrainStatus(`Готово: модель ${ollama.name} в Ollama — вкладка «Модели»`);
|
||||
SA.app?.refreshModels?.();
|
||||
} else if (ollama?.skipped) {
|
||||
setTrainStatus(ollama.note || ollama.error || 'Адаптер сохранён, Ollama — вручную');
|
||||
setTrainStatus(ollama.note || ollama.error || 'Адаптер сохранён — см. вкладку «Модели»');
|
||||
} else if (ollama?.error) {
|
||||
setTrainStatus(`Обучение OK, Ollama: ${ollama.error}`);
|
||||
} else if (status === 'completed_with_warnings') {
|
||||
setTrainStatus('Обучение завершено с предупреждениями — см. лог');
|
||||
setTrainStatus('Завершено с предупреждениями — лог на «Модели»');
|
||||
} else {
|
||||
setTrainStatus('QLoRA завершено');
|
||||
setTrainStatus('QLoRA завершено — вкладка «Модели»');
|
||||
}
|
||||
setTrainingTab('models');
|
||||
await refreshTrainModels();
|
||||
} else if (status === 'failed') {
|
||||
setTrainStatus(`Ошибка тренировки (exit ${prog?.exit_code ?? '?'})`);
|
||||
}
|
||||
@@ -509,6 +671,18 @@ export function attachTraining(SA) {
|
||||
}
|
||||
|
||||
async function startQlora() {
|
||||
const baseModel = getQloraHfBase();
|
||||
const outputName = ($('sa_qlora_name')?.value || '').trim();
|
||||
if (!baseModel) {
|
||||
setTrainStatus('Выберите HF base model из списка или укажите свой HF id');
|
||||
$('sa_qlora_base')?.focus();
|
||||
return;
|
||||
}
|
||||
if (!outputName) {
|
||||
setTrainStatus('Укажите имя модели в Ollama (например my-lora:v1)');
|
||||
$('sa_qlora_name')?.focus();
|
||||
return;
|
||||
}
|
||||
setTrainStatus('Запуск…');
|
||||
try {
|
||||
const hfDs = ($('sa_qlora_hf_dataset')?.value || '').trim();
|
||||
@@ -516,9 +690,9 @@ export function attachTraining(SA) {
|
||||
await SA.request('AssistentStartTrainJob', {
|
||||
base_url: $('sa_base_url')?.value,
|
||||
chat_model: $('sa_model')?.value,
|
||||
base_model: $('sa_qlora_base')?.value,
|
||||
base_model: baseModel,
|
||||
ollama_base: $('sa_qlora_ollama_base')?.value,
|
||||
output_name: $('sa_qlora_name')?.value,
|
||||
output_name: outputName,
|
||||
rank: Number($('sa_qlora_rank')?.value) || 16,
|
||||
alpha: Number($('sa_qlora_alpha')?.value) || 32,
|
||||
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
|
||||
@@ -556,13 +730,20 @@ export function attachTraining(SA) {
|
||||
const root = $('sa_train_models_list');
|
||||
if (!root) return;
|
||||
try {
|
||||
const jobData = await SA.request('AssistentGetTrainJob', {});
|
||||
renderLastTrainJob(jobData?.last_job || jobData?.job);
|
||||
const data = await SA.request('AssistentListModels', { baseUrl: $('sa_base_url')?.value });
|
||||
const models = data?.models || [];
|
||||
const lastOut = jobData?.last_job?.output_name;
|
||||
root.innerHTML = models.length
|
||||
? models.map((m) => `<div class="sa-hf-row"><strong>${escapeHtml(m)}</strong></div>`).join('')
|
||||
: '<div class="sa-mem-empty">Нет моделей</div>';
|
||||
? models.map((m) => {
|
||||
const hit = lastOut && String(m).includes(String(lastOut).split(':')[0]);
|
||||
return `<div class="sa-hf-row${hit ? ' sa-train-model-new' : ''}"><strong>${escapeHtml(m)}</strong>${hit ? ' · последняя тренировка' : ''}</div>`;
|
||||
}).join('')
|
||||
: '<div class="sa-mem-empty">Нет моделей в Ollama — после QLoRA нажми «Обновить» или проверь лог тренировки</div>';
|
||||
} catch (e) {
|
||||
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(e.message)}</div>`;
|
||||
const msg = String(e.message || e);
|
||||
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(isSqliteError(msg) ? sqliteHint() : msg)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,6 +877,7 @@ export function attachTraining(SA) {
|
||||
r.addEventListener('change', () => setTrainMode(r.value));
|
||||
});
|
||||
$('sa_btn_modelfile_create')?.addEventListener('click', createModelfile);
|
||||
$('sa_qlora_base')?.addEventListener('change', () => applyQloraPresetFromSelect());
|
||||
$('sa_btn_qlora_start')?.addEventListener('click', startQlora);
|
||||
$('sa_btn_qlora_cancel')?.addEventListener('click', cancelQlora);
|
||||
$('sa_btn_train_models_refresh')?.addEventListener('click', refreshTrainModels);
|
||||
@@ -710,6 +892,15 @@ export function attachTraining(SA) {
|
||||
wireTraining();
|
||||
setTrainingTab(state.ttab);
|
||||
},
|
||||
onConfig(data) {
|
||||
if (data?.training && typeof data.training === 'object') {
|
||||
state.qloraTraining = data.training;
|
||||
if (state.ttab === 'train') {
|
||||
populateQloraHfPresets(state.qloraTraining);
|
||||
applyQloraPresetFromSelect({ fillName: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
resumePolling: resumeTrainJobPolling,
|
||||
async curateFromChat(messages, meta) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user