Ship Assistent 0.13.0: real QLoRA pipeline and GGUF Ollama register.

Replace fake train loop with TRL SFTTrainer, HF column mapping with fiction preset,
safetensors to GGUF conversion, and ollama create using ollama_base plus ADAPTER.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 14:45:07 +03:00
co-authored by Cursor
parent 1a03c3178f
commit 474a674a35
12 changed files with 1002 additions and 133 deletions
+118 -6
View File
@@ -17,6 +17,7 @@ export function attachTraining(SA) {
hfResults: [],
hfSelected: null,
hfCheck: null,
hfMapping: null,
trainWs: null,
polling: null,
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
@@ -97,7 +98,10 @@ export function attachTraining(SA) {
refreshSamples();
loadAgentHeardSettings();
}
if (state.ttab === 'train') syncModelfileModels();
if (state.ttab === 'train') {
syncModelfileModels();
syncQloraModels();
}
if (state.ttab === 'models') refreshTrainModels();
}
@@ -162,6 +166,81 @@ export function attachTraining(SA) {
await refreshSamples();
}
function hfStringColumns(check) {
const cols = check?.schema?.columns;
if (Array.isArray(cols) && cols.length) return cols;
const feats = check?.features;
if (Array.isArray(feats)) {
return feats.map((f) => f?.name).filter(Boolean);
}
if (feats && typeof feats === 'object') return Object.keys(feats);
return [];
}
function renderHfMappingUI(check) {
const row = $('sa_hf_mapping_row');
if (!row) return;
const gate = check?.gate;
const schemaKind = check?.schema?.kind;
const needsMapping = gate === 'mapping' || schemaKind === 'fiction_tags_text';
row.hidden = !needsMapping;
if (!needsMapping) {
state.hfMapping = null;
return;
}
const cols = hfStringColumns(check);
const userSel = $('sa_hf_user_col');
const asstSel = $('sa_hf_asst_col');
const presetSel = $('sa_hf_mapping_preset');
if (userSel) {
userSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
if (cols.includes('tags')) userSel.value = 'tags';
else if (cols.includes('title')) userSel.value = 'title';
}
if (asstSel) {
asstSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
if (cols.includes('text')) asstSel.value = 'text';
else if (cols.includes('output')) asstSel.value = 'output';
}
if (schemaKind === 'fiction_tags_text' && presetSel) {
presetSel.value = 'fiction_tags_text';
state.hfMapping = { kind: 'fiction_tags_text', preset: 'fiction_tags_text' };
}
}
function buildHfMappingPayload() {
const preset = $('sa_hf_mapping_preset')?.value;
if (preset === 'fiction_tags_text') {
return { kind: 'fiction_tags_text', preset: 'fiction_tags_text' };
}
const userCol = $('sa_hf_user_col')?.value;
const asstCol = $('sa_hf_asst_col')?.value;
if (userCol && asstCol) {
return { kind: 'custom', user_col: userCol, assistant_col: asstCol };
}
return state.hfMapping;
}
async function syncQloraModels() {
try {
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
const data = await SA.request('AssistentListModels', { baseUrl });
const models = data?.models || [];
const sel = $('sa_qlora_ollama_base');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">—</option>';
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
sel.appendChild(opt);
}
if (cur) sel.value = cur;
else if ($('sa_model')?.value) sel.value = $('sa_model').value;
} catch (e) { /* ignore */ }
}
function renderHfList() {
const root = $('sa_hf_list');
if (!root) return;
@@ -216,6 +295,7 @@ export function attachTraining(SA) {
}
const importRow = $('sa_hf_import_row');
if (importRow) importRow.hidden = data.gate === 'rejected';
renderHfMappingUI(data);
} catch (e) {
if (status) status.textContent = String(e.message || e);
}
@@ -228,8 +308,9 @@ export function attachTraining(SA) {
}
const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200;
const mapping = buildHfMappingPayload();
try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit });
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping });
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
await refreshSamples();
} catch (e) {
@@ -303,8 +384,9 @@ export function attachTraining(SA) {
async function pollTrainJob() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null;
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 logEl = $('sa_train_log');
const bar = $('sa_train_progress_fill');
@@ -318,6 +400,24 @@ export function attachTraining(SA) {
clearInterval(state.polling);
state.polling = null;
$('sa_btn_qlora_cancel').hidden = true;
setTrainingLock(false);
if (status === 'completed' || status === 'completed_with_warnings') {
const ollama = prog?.ollama;
if (ollama?.success) {
setTrainStatus(`Готово: модель ${ollama.name} в Ollama`);
SA.app?.refreshModels?.();
} else if (ollama?.skipped) {
setTrainStatus(ollama.note || ollama.error || 'Адаптер сохранён, Ollama — вручную');
} else if (ollama?.error) {
setTrainStatus(`Обучение OK, Ollama: ${ollama.error}`);
} else if (status === 'completed_with_warnings') {
setTrainStatus('Обучение завершено с предупреждениями — см. лог');
} else {
setTrainStatus('QLoRA завершено');
}
} else if (status === 'failed') {
setTrainStatus(`Ошибка тренировки (exit ${prog?.exit_code ?? '?'})`);
}
}
} catch (e) { /* ignore */ }
}
@@ -325,18 +425,23 @@ export function attachTraining(SA) {
async function startQlora() {
setTrainStatus('Запуск…');
try {
const hfDs = ($('sa_qlora_hf_dataset')?.value || '').trim();
const mapping = hfDs ? buildHfMappingPayload() : undefined;
await SA.request('AssistentStartTrainJob', {
base_url: $('sa_base_url')?.value,
chat_model: $('sa_model')?.value,
base_model: $('sa_qlora_base')?.value,
ollama_base: $('sa_qlora_ollama_base')?.value,
output_name: $('sa_qlora_name')?.value,
rank: Number($('sa_qlora_rank')?.value) || 16,
alpha: Number($('sa_qlora_alpha')?.value) || 32,
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
epochs: Number($('sa_qlora_epochs')?.value) || 3,
seq_len: Number($('sa_qlora_seq')?.value) || 2048,
max_samples: Number($('sa_qlora_max_samples')?.value) || 0,
four_bit: !!$('sa_qlora_4bit')?.checked,
hf_dataset: ($('sa_qlora_hf_dataset')?.value || '').trim() || undefined,
hf_dataset: hfDs || undefined,
hf_mapping: mapping,
});
$('sa_btn_qlora_cancel').hidden = false;
setTrainingLock(true, 'Идёт тренировка…');
@@ -377,10 +482,12 @@ export function attachTraining(SA) {
try {
await SA.request('AssistentSaveRunnerSettings', {
python: $('sa_runner_python')?.value,
kind: $('sa_runner_kind')?.value,
kind: $('sa_runner_kind')?.value || 'builtin',
workdir: $('sa_runner_workdir')?.value,
cmd: $('sa_runner_cmd')?.value,
gguf_script: $('sa_runner_gguf_script')?.value,
gguf_base_path: $('sa_runner_gguf_base')?.value,
gguf_cmd: $('sa_runner_gguf_cmd')?.value,
});
setTrainStatus('Раннер сохранён');
} catch (e) {
@@ -393,10 +500,12 @@ export function attachTraining(SA) {
const data = await SA.request('AssistentGetRunnerSettings', {});
const s = data?.settings || {};
if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python;
if ($('sa_runner_kind') && s.kind) $('sa_runner_kind').value = s.kind;
if ($('sa_runner_kind')) $('sa_runner_kind').value = s.kind || 'builtin';
if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir;
if ($('sa_runner_cmd') && s.cmd) $('sa_runner_cmd').value = s.cmd;
if ($('sa_runner_gguf_script') && s.gguf_script) $('sa_runner_gguf_script').value = s.gguf_script;
if ($('sa_runner_gguf_base') && s.gguf_base_path) $('sa_runner_gguf_base').value = s.gguf_base_path;
if ($('sa_runner_gguf_cmd') && s.gguf_cmd) $('sa_runner_gguf_cmd').value = s.gguf_cmd;
} catch (e) { /* ignore */ }
}
@@ -491,6 +600,9 @@ export function attachTraining(SA) {
await checkHfLink();
});
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
$('sa_hf_mapping_preset')?.addEventListener('change', () => {
state.hfMapping = buildHfMappingPayload();
});
$('sa_btn_hf_import')?.addEventListener('click', importHf);
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
r.addEventListener('change', () => setTrainMode(r.value));