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:
+118
-6
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user