Ship Assistent 0.12.1: training tab, dataset pipeline, and heard RAG.

Restructure UI with app-level tabs and chat history drawer; add dataset curation,
HF import, Modelfile/QLoRA hooks, and link approved samples to the agent immediately
via heard vector memory without waiting for fine-tuning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 14:27:59 +03:00
co-authored by Cursor
parent e8bb012885
commit 1a03c3178f
21 changed files with 4919 additions and 638 deletions
+127 -20
View File
@@ -20,6 +20,7 @@
const LS_WELCOMED = 'swarm_assistent_welcomed';
const LS_CHATS = 'swarm_assistent_chats_v1';
const LS_BOARD_TAB = 'swarm_assistent_board_tab';
const LS_CHATS_DRAWER = 'swarm_assistent_chats_drawer';
const MAX_CHATS = 40;
const MAX_CHAT_MSGS = 24;
const TAB_BUTTON_ID = 'maintab_assistent';
@@ -167,6 +168,7 @@
activeChatId: null,
restoringChat: false,
chatsPanelOpen: false,
chatsDrawerOpen: false,
chatsQuery: '',
chatsSearchHits: null,
slashIndex: 0,
@@ -179,6 +181,7 @@
wanted: { count: 0, items: [] },
wantedKeys: new Set(),
ollamaHealth: 'unknown',
trainingLock: false,
};
// ---- Turn lifecycle --------------------------------------------------
@@ -2913,12 +2916,17 @@
function setChatsPanelOpen(open) {
state.chatsPanelOpen = !!open;
state.chatsDrawerOpen = state.chatsPanelOpen;
const panel = $('sa_chats_panel');
const btn = $('sa_btn_chats');
const root = $('swarm_assistent_root');
if (panel) {
panel.hidden = !state.chatsPanelOpen;
}
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
btn?.classList.toggle('sa-sessions-toggle-active', state.chatsPanelOpen);
root?.classList.toggle('sa-drawer-open', state.chatsPanelOpen);
localStorage.setItem(LS_CHATS_DRAWER, state.chatsPanelOpen ? '1' : '0');
if (state.chatsPanelOpen) {
saveActiveChatToStore();
const search = $('sa_chats_search');
@@ -2928,6 +2936,7 @@
}
renderChatsList();
}
saveUiStateToDisk();
}
async function startNewChat({ saveCurrent = true, force = false } = {}) {
@@ -2939,7 +2948,6 @@
// Drop in-flight reply so it cannot land in the new chat.
abortInFlightWork({ status: '' });
}
setChatsPanelOpen(false);
if (saveCurrent) {
saveActiveChatToStore({ dropEmpty: true });
}
@@ -2979,7 +2987,6 @@
async function switchToChat(id) {
if (!id || id === state.activeChatId) {
setChatsPanelOpen(false);
return;
}
if (state.busy || state.generating) {
@@ -3024,7 +3031,6 @@
updateSessionLabel();
syncHistoryBadge();
renderChatsList();
setChatsPanelOpen(false);
setView('chat');
if (result?.restored) {
setStatus(`Чат «${chat.title}» · параметры восстановлены`);
@@ -4962,6 +4968,9 @@
if (civitaiResults && civitaiResults.length) {
div.appendChild(buildCivitaiCards(civitaiResults));
}
if (role === 'assistant' && !(meta && meta.historical)) {
mountCurateButtons(div, meta);
}
box.appendChild(div);
scrollMessagesToBottom({ force: true });
return div;
@@ -5087,9 +5096,78 @@
if (civitaiResults && civitaiResults.length) {
el.appendChild(buildCivitaiCards(civitaiResults));
}
if (!(meta && meta.historical)) {
mountCurateButtons(el, meta);
}
scrollMessagesToBottom();
}
function mountCurateButtons(msgEl, meta) {
if (!msgEl || msgEl.querySelector('.sa-msg-curate')) {
return;
}
const wrap = document.createElement('div');
wrap.className = 'sa-msg-curate';
const ok = document.createElement('button');
ok.type = 'button';
ok.className = 'basic-button';
ok.title = 'В датасет (одобрить)';
ok.textContent = '+ датасет';
ok.addEventListener('click', () => curateAssistantMessage(msgEl, 'approved'));
const bad = document.createElement('button');
bad.type = 'button';
bad.className = 'basic-button';
bad.title = 'Отклонить для датасета';
bad.textContent = 'брак';
bad.addEventListener('click', () => curateAssistantMessage(msgEl, 'rejected'));
wrap.appendChild(ok);
wrap.appendChild(bad);
msgEl.appendChild(wrap);
}
function curateAssistantMessage(msgEl, status) {
const hist = state.history || [];
let asstText = msgEl.querySelector('.sa-msg-body')?.textContent?.trim() || msgEl.textContent?.trim() || '';
let userText = '';
for (let i = hist.length - 1; i >= 0; i--) {
if (hist[i]?.role === 'assistant' && (hist[i].content || '').trim() === asstText.trim()) {
for (let j = i - 1; j >= 0; j--) {
if (hist[j]?.role === 'user') {
userText = hist[j].content || '';
break;
}
}
break;
}
}
if (!userText) {
for (let i = hist.length - 1; i >= 0; i--) {
if (hist[i]?.role === 'user') {
userText = hist[i].content || '';
break;
}
}
}
const messages = [
{ role: 'user', content: userText },
{ role: 'assistant', content: asstText },
];
window.SA?.training?.curateFromChat?.(messages, {
chatId: state.activeChatId,
persona: $('sa_persona')?.value || 'neutral',
pack: $('sa_pack')?.value || defaultPackId(),
status,
})?.then?.((ok) => {
if (ok) {
setStatus(status === 'approved' ? 'Пример добавлен в датасет' : 'Пример отмечен как брак');
}
});
}
function isTrainingLocked() {
return !!state.trainingLock || document.getElementById('swarm_assistent_root')?.classList.contains('sa-root-training-lock');
}
function buildCivitaiCards(results) {
const list = document.createElement('div');
list.className = 'sa-civitai-list';
@@ -5467,9 +5545,14 @@
if (paneW) {
document.documentElement.style.setProperty('--sa-image-width', paneW);
}
if (view === 'cards' || view === 'chat' || view === 'settings') {
if (view === 'cards' || view === 'chat' || view === 'settings' || view === 'train') {
state.view = view;
}
const drawer = localStorage.getItem(LS_CHATS_DRAWER);
if (drawer != null) {
state.chatsDrawerOpen = drawer === '1';
state.chatsPanelOpen = state.chatsDrawerOpen;
}
const boardTab = localStorage.getItem(LS_BOARD_TAB);
if (boardTab === 'refs' || boardTab === 'generate') {
state.boardTab = boardTab;
@@ -5492,6 +5575,7 @@
model: $('sa_model')?.value || '',
view: state.view || 'chat',
board_tab: state.boardTab || 'generate',
chats_drawer: state.chatsDrawerOpen ? '1' : '0',
};
}
@@ -5527,12 +5611,18 @@
fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') {
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings' || ui.view === 'train') {
fill(LS_VIEW, ui.view, (v) => { state.view = v; });
}
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
}
if (ui.chats_drawer != null && localStorage.getItem(LS_CHATS_DRAWER) == null) {
const open = ui.chats_drawer === true || ui.chats_drawer === '1' || ui.chats_drawer === 1;
localStorage.setItem(LS_CHATS_DRAWER, open ? '1' : '0');
state.chatsDrawerOpen = open;
state.chatsPanelOpen = open;
}
for (const [key, lsKey, id] of [
['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
@@ -7093,12 +7183,15 @@
state.view = 'cards';
} else if (view === 'settings') {
state.view = 'settings';
} else if (view === 'train') {
state.view = 'train';
} else {
state.view = 'chat';
}
const chat = $('sa_view_chat');
const cards = $('sa_view_cards');
const settings = $('sa_view_settings');
const train = $('sa_view_train');
if (chat) {
chat.hidden = state.view !== 'chat';
}
@@ -7108,18 +7201,25 @@
if (settings) {
settings.hidden = state.view !== 'settings';
}
$('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
$('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
$('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
$('sa_tab_chat')?.setAttribute('aria-selected', state.view === 'chat' ? 'true' : 'false');
$('sa_tab_cards')?.setAttribute('aria-selected', state.view === 'cards' ? 'true' : 'false');
$('sa_tab_settings')?.setAttribute('aria-selected', state.view === 'settings' ? 'true' : 'false');
if (train) {
train.hidden = state.view !== 'train';
}
const tabActive = (id, on) => {
$(id)?.classList.toggle('sa-subtab-active', on);
$(id)?.classList.toggle('sa-app-tab-active', on);
$(id)?.setAttribute('aria-selected', on ? 'true' : 'false');
};
tabActive('sa_tab_chat', state.view === 'chat');
tabActive('sa_tab_cards', state.view === 'cards');
tabActive('sa_tab_settings', state.view === 'settings');
tabActive('sa_tab_train', state.view === 'train');
saveSettings();
if (state.view === 'cards') {
renderCardsList();
} else if (state.view === 'settings') {
setSettingsTab(state.settingsTab || 'behavior');
} else if (state.view === 'train') {
window.SA?.training?.render?.();
} else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
// Back in the chat — bring the model home (Krea may have evicted it).
warmLlm({ force: true });
@@ -8403,6 +8503,10 @@
if ((state.busy || state.generating) && !isContinuationTurn(opts)) {
return;
}
if (isTrainingLocked() && !isContinuationTurn(opts)) {
setStatus('Идёт тренировка — чат заблокирован');
return;
}
const rawInput = ($('sa_input')?.value || '').trim();
const text = (opts.forcedUserText || rawInput).trim();
if (!text) {
@@ -8975,6 +9079,7 @@
refreshImagePreview();
}
bootstrapPersisted();
setChatsPanelOpen(!!state.chatsDrawerOpen);
wireDropZone();
wireSplitter();
registerSendButton();
@@ -8986,6 +9091,7 @@
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
});
$('sa_btn_chats_close')?.addEventListener('click', () => setChatsPanelOpen(false));
$('sa_session_label')?.addEventListener('click', (e) => {
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
@@ -9039,6 +9145,7 @@
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
$('sa_tab_train')?.addEventListener('click', () => setView('train'));
$('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
@@ -9050,14 +9157,6 @@
$('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
$('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
$('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
$('sa_btn_settings')?.addEventListener('click', () => {
if (state.view === 'settings') {
closeSettings();
} else {
openSettings(state.settingsTab || 'behavior');
}
});
$('sa_settings_close')?.addEventListener('click', () => closeSettings());
document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
});
@@ -9348,6 +9447,14 @@
}
}, 30000);
window.SA = window.SA || {};
window.SA.app = {
getState: () => state,
setTrainingLock(on) { state.trainingLock = !!on; },
refreshModels: () => refreshModels(),
setStatus: (msg) => setStatus(msg),
};
window.swarmAssistent = {
setImageFromSrc,
putImageOnBoard,
+3
View File
@@ -17,3 +17,6 @@ window.SA.applyConfigPatchKeys = function (config) {
};
import './app.js';
import { attachTraining } from './training.js';
attachTraining(window.SA);
+531
View File
@@ -0,0 +1,531 @@
/** Swarm Assistent — training tab (dataset, Modelfile, QLoRA). */
const $ = (id) => document.getElementById(id);
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export function attachTraining(SA) {
const state = {
ttab: 'dataset',
samples: [],
hfResults: [],
hfSelected: null,
hfCheck: null,
trainWs: null,
polling: null,
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
agentLinked: 0,
};
function setAgentHeardStats(linked) {
const el = $('sa_agent_heard_stats');
if (el) {
el.textContent = `Подключено: ${linked ?? state.agentLinked ?? '—'}`;
}
}
async function loadAgentHeardSettings() {
try {
const data = await SA.request('AssistentGetDatasetAgentSettings', {});
const s = data?.settings || {};
state.agentSettings = {
enabled: s.enabled !== false,
auto_link_on_approve: s.auto_link_on_approve !== false,
heard_quota: s.heard_quota ?? 3,
};
state.agentLinked = data?.linked ?? 0;
if ($('sa_agent_heard_enabled')) $('sa_agent_heard_enabled').checked = state.agentSettings.enabled;
if ($('sa_agent_auto_link')) $('sa_agent_auto_link').checked = state.agentSettings.auto_link_on_approve;
if ($('sa_agent_heard_quota')) $('sa_agent_heard_quota').value = String(state.agentSettings.heard_quota);
setAgentHeardStats(state.agentLinked);
} catch (e) {
console.warn('loadAgentHeardSettings', e);
}
}
async function saveAgentHeardSettings() {
const settings = {
enabled: !!$('sa_agent_heard_enabled')?.checked,
auto_link_on_approve: !!$('sa_agent_auto_link')?.checked,
heard_quota: Math.max(0, Math.min(8, parseInt($('sa_agent_heard_quota')?.value, 10) || 3)),
};
try {
const data = await SA.request('AssistentSaveDatasetAgentSettings', { settings });
state.agentSettings = data?.settings || settings;
setTrainStatus('Настройки «услышанного» сохранены');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function syncAllToAgent() {
setTrainStatus('Подключение к агенту…');
try {
await saveAgentHeardSettings();
const data = await SA.request('AssistentSyncDatasetToAgent', { approved_only: true, relink: false });
state.agentLinked = data?.total_linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus(`Подключено: +${data?.linked_now ?? 0}, всего ${data?.total_linked ?? '—'}`);
await refreshSamples();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
function setTrainStatus(msg) {
const el = $('sa_train_status');
if (el) el.textContent = msg || '';
}
function setTrainingTab(id) {
state.ttab = id || 'dataset';
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
const on = btn.getAttribute('data-ttab') === state.ttab;
btn.classList.toggle('sa-ttab-active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('#sa_training .sa-tpane').forEach((pane) => {
pane.hidden = pane.getAttribute('data-tpane') !== state.ttab;
});
if (state.ttab === 'dataset') {
refreshSamples();
loadAgentHeardSettings();
}
if (state.ttab === 'train') syncModelfileModels();
if (state.ttab === 'models') refreshTrainModels();
}
async function refreshSamples() {
try {
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 });
state.samples = data?.samples || [];
const stats = $('sa_train_stats');
if (stats) stats.textContent = `Одобрено: ${data?.approved ?? '—'} · всего: ${data?.total ?? '—'}`;
const personaSel = $('sa_train_filter_persona');
if (personaSel && $('sa_persona')) {
const cur = personaSel.value || 'all';
personaSel.innerHTML = '<option value="all">Все личности</option>';
for (const opt of $('sa_persona').options) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.textContent;
personaSel.appendChild(o);
}
personaSel.value = cur;
}
renderSamples();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
function renderSamples() {
const root = $('sa_train_samples');
if (!root) return;
if (!state.samples.length) {
root.innerHTML = '<div class="sa-mem-empty">Нет примеров. Отметь ответы в чате или импортируй датасет.</div>';
return;
}
root.innerHTML = '';
for (const s of state.samples) {
const div = document.createElement('div');
div.className = 'sa-train-sample';
div.dataset.id = s.id;
const msgs = s.messages || [];
const preview = msgs.map((m) => `${m.role}: ${(m.content || '').slice(0, 120)}`).join('\n');
const linked = s.agent_linked ? ' · 🔗 агент' : '';
div.innerHTML = `
<div class="sa-train-sample-head">
<span class="sa-hf-badge sa-hf-badge-${s.status === 'approved' ? 'ok' : s.status === 'rejected' ? 'no' : 'map'}">${escapeHtml(s.status)}</span>
<span>${escapeHtml(s.source)} · ${escapeHtml(s.persona || '—')} · ${escapeHtml(s.pack || '—')}${linked}</span>
<button type="button" class="basic-button" data-approve="1">✓</button>
<button type="button" class="basic-button" data-reject="1">✕</button>
<button type="button" class="basic-button" data-link="1" title="Подключить к агенту">🔗</button>
<button type="button" class="basic-button" data-unlink="1" title="Отключить от агента">⛓</button>
<button type="button" class="basic-button sa-danger-btn" data-del="1">Удалить</button>
</div>
<textarea spellcheck="false">${escapeHtml(preview)}</textarea>`;
root.appendChild(div);
}
}
async function upsertSample(patch) {
await SA.request('AssistentUpsertTrainSample', patch);
await refreshSamples();
}
function renderHfList() {
const root = $('sa_hf_list');
if (!root) return;
root.innerHTML = '';
const showAll = !!$('sa_hf_show_all')?.checked;
for (const r of state.hfResults) {
if (!showAll && r.gate === 'rejected') continue;
const row = document.createElement('div');
row.className = 'sa-hf-row' + (state.hfSelected === r.id ? ' sa-hf-row-active' : '') + (r.gate === 'rejected' ? ' sa-hf-rejected' : '');
row.dataset.id = r.id;
const badge = r.gate === 'ok' ? 'ok' : r.gate === 'mapping' ? 'map' : 'no';
row.innerHTML = `<span class="sa-hf-badge sa-hf-badge-${badge}">${escapeHtml(r.gate)}</span><strong>${escapeHtml(r.id)}</strong><span>${escapeHtml(r.reason || '')}</span>`;
root.appendChild(row);
}
}
async function searchHf() {
const q = ($('sa_hf_search')?.value || '').trim();
setTrainStatus('Поиск…');
try {
const data = await SA.request('AssistentSearchHfDatasets', {
q,
limit: 24,
show_all: !!$('sa_hf_show_all')?.checked,
});
state.hfResults = data?.results || [];
renderHfList();
setTrainStatus(`Найдено: ${state.hfResults.length}`);
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function checkHfLink() {
const link = ($('sa_hf_link')?.value || '').trim();
const status = $('sa_hf_status');
if (!link) return;
if (status) status.textContent = 'Проверяю…';
try {
const data = await SA.request('AssistentCheckHfDataset', { dataset: link });
state.hfCheck = data;
state.hfSelected = data.id;
if (status) {
status.textContent = data.gate === 'rejected'
? `Отклонено: ${data.reason}`
: `${data.gate}: ${data.reason || 'OK'}`;
}
const preview = $('sa_hf_preview');
if (preview) {
preview.hidden = false;
preview.textContent = JSON.stringify(data.sample_rows || data.features || data, null, 2).slice(0, 8000);
}
const importRow = $('sa_hf_import_row');
if (importRow) importRow.hidden = data.gate === 'rejected';
} catch (e) {
if (status) status.textContent = String(e.message || e);
}
}
async function importHf() {
if (!state.hfSelected && !state.hfCheck?.id) {
setTrainStatus('Сначала проверь набор');
return;
}
const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200;
try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit });
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
await refreshSamples();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function syncModelfileModels() {
try {
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
const data = await SA.request('AssistentListModels', { baseUrl });
const models = data?.models || [];
for (const selId of ['sa_modelfile_base']) {
const sel = $(selId);
if (!sel) continue;
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;
}
const personaSel = $('sa_modelfile_persona');
if (personaSel && $('sa_persona')) {
personaSel.innerHTML = $('sa_persona').innerHTML;
personaSel.value = $('sa_persona').value || 'neutral';
}
} catch (e) { /* ignore */ }
}
async function createModelfile() {
setTrainStatus('Создаю модель…');
try {
const data = await SA.request('AssistentCreateOllamaModel', {
base_url: $('sa_base_url')?.value,
base_model: $('sa_modelfile_base')?.value,
name: $('sa_modelfile_name')?.value,
persona: $('sa_modelfile_persona')?.value,
system: $('sa_modelfile_system')?.value,
shots: Number($('sa_modelfile_shots')?.value) || 8,
num_ctx: Number($('sa_modelfile_num_ctx')?.value) || 16384,
temperature: Number($('sa_modelfile_temp')?.value) || 0.7,
});
setTrainStatus(`Готово: ${data.name}`);
SA.app?.refreshModels?.();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
function setTrainMode(mode) {
$('sa_train_form_modelfile').hidden = mode !== 'modelfile';
$('sa_train_form_qlora').hidden = mode !== 'qlora';
}
function setTrainingLock(on, text) {
const root = $('swarm_assistent_root');
const banner = $('sa_train_banner');
if (root) root.classList.toggle('sa-root-training-lock', !!on);
if (banner) {
banner.hidden = !on;
const t = $('sa_train_banner_text');
if (t && text) t.textContent = text;
}
SA.app?.setTrainingLock?.(!!on);
}
async function pollTrainJob() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null;
const active = data?.training_active || data?.job?.status === 'running';
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…');
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 (logEl && prog.log) logEl.textContent = prog.log;
}
if (!active) {
clearInterval(state.polling);
state.polling = null;
$('sa_btn_qlora_cancel').hidden = true;
}
} catch (e) { /* ignore */ }
}
async function startQlora() {
setTrainStatus('Запуск…');
try {
await SA.request('AssistentStartTrainJob', {
base_url: $('sa_base_url')?.value,
chat_model: $('sa_model')?.value,
base_model: $('sa_qlora_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,
four_bit: !!$('sa_qlora_4bit')?.checked,
hf_dataset: ($('sa_qlora_hf_dataset')?.value || '').trim() || undefined,
});
$('sa_btn_qlora_cancel').hidden = false;
setTrainingLock(true, 'Идёт тренировка…');
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
pollTrainJob();
setTrainStatus('Тренировка запущена');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function cancelQlora() {
try {
await SA.request('AssistentCancelTrainJob', {});
setTrainingLock(false);
setTrainStatus('Отменено');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function refreshTrainModels() {
const root = $('sa_train_models_list');
if (!root) return;
try {
const data = await SA.request('AssistentListModels', { baseUrl: $('sa_base_url')?.value });
const models = data?.models || [];
root.innerHTML = models.length
? models.map((m) => `<div class="sa-hf-row"><strong>${escapeHtml(m)}</strong></div>`).join('')
: '<div class="sa-mem-empty">Нет моделей</div>';
} catch (e) {
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(e.message)}</div>`;
}
}
async function saveRunner() {
try {
await SA.request('AssistentSaveRunnerSettings', {
python: $('sa_runner_python')?.value,
kind: $('sa_runner_kind')?.value,
workdir: $('sa_runner_workdir')?.value,
cmd: $('sa_runner_cmd')?.value,
gguf_script: $('sa_runner_gguf_script')?.value,
});
setTrainStatus('Раннер сохранён');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function loadRunner() {
try {
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_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;
} catch (e) { /* ignore */ }
}
function wireTraining() {
if (window.__saTrainingWired) return;
window.__saTrainingWired = true;
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
btn.addEventListener('click', () => setTrainingTab(btn.getAttribute('data-ttab')));
});
$('sa_btn_agent_sync')?.addEventListener('click', syncAllToAgent);
$('sa_agent_heard_enabled')?.addEventListener('change', saveAgentHeardSettings);
$('sa_agent_auto_link')?.addEventListener('change', saveAgentHeardSettings);
$('sa_agent_heard_quota')?.addEventListener('change', saveAgentHeardSettings);
loadAgentHeardSettings();
$('sa_btn_train_from_chats')?.addEventListener('click', async () => {
try {
const data = await SA.request('AssistentBuildDatasetFromChats', {});
setTrainStatus(`Из чатов: +${data.added}`);
await refreshSamples();
} catch (e) { setTrainStatus(String(e.message || e)); }
});
$('sa_btn_train_import_file')?.addEventListener('click', () => $('sa_train_import_file')?.click());
$('sa_train_import_file')?.addEventListener('change', async (e) => {
const file = e.target?.files?.[0];
if (!file) return;
const text = await file.text();
try {
const data = await SA.request('AssistentImportDataset', { format: 'auto', content: text });
setTrainStatus(`Импорт: ${data.imported}`);
await refreshSamples();
} catch (err) { setTrainStatus(String(err.message || err)); }
e.target.value = '';
});
$('sa_btn_train_export')?.addEventListener('click', async () => {
try {
const data = await SA.request('AssistentExportDataset', { status: 'approved' });
if (data.content) {
const blob = new Blob([data.content], { type: 'application/jsonl' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'assistent-dataset.jsonl';
a.click();
}
setTrainStatus(`Экспорт: ${data.count} примеров`);
} catch (e) { setTrainStatus(String(e.message || e)); }
});
$('sa_train_filter_status')?.addEventListener('change', refreshSamples);
$('sa_train_filter_persona')?.addEventListener('change', refreshSamples);
$('sa_train_samples')?.addEventListener('click', async (e) => {
const row = e.target.closest('.sa-train-sample');
if (!row) return;
const id = row.dataset.id;
const sample = state.samples.find((s) => s.id === id);
if (!sample) return;
if (e.target.closest('[data-approve]')) {
await upsertSample({ ...sample, status: 'approved' });
await loadAgentHeardSettings();
} else if (e.target.closest('[data-reject]')) {
await upsertSample({ ...sample, status: 'rejected' });
await loadAgentHeardSettings();
} else if (e.target.closest('[data-link]')) {
try {
const data = await SA.request('AssistentLinkTrainSampleToAgent', { id });
state.agentLinked = data?.linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus('Пример подключён к агенту');
await refreshSamples();
} catch (err) { setTrainStatus(String(err.message || err)); }
} else if (e.target.closest('[data-unlink]')) {
try {
const data = await SA.request('AssistentUnlinkTrainSampleFromAgent', { id });
state.agentLinked = data?.linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus('Пример отключён от агента');
await refreshSamples();
} catch (err) { setTrainStatus(String(err.message || err)); }
} else if (e.target.closest('[data-del]')) {
if (window.confirm('Удалить пример?')) {
await SA.request('AssistentDeleteTrainSample', { id });
await refreshSamples();
}
}
});
$('sa_btn_hf_search')?.addEventListener('click', searchHf);
$('sa_hf_show_all')?.addEventListener('change', () => { renderHfList(); });
$('sa_hf_list')?.addEventListener('click', async (e) => {
const row = e.target.closest('.sa-hf-row');
if (!row || row.classList.contains('sa-hf-rejected')) return;
state.hfSelected = row.dataset.id;
$('sa_hf_link').value = row.dataset.id;
renderHfList();
await checkHfLink();
});
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
$('sa_btn_hf_import')?.addEventListener('click', importHf);
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
r.addEventListener('change', () => setTrainMode(r.value));
});
$('sa_btn_modelfile_create')?.addEventListener('click', createModelfile);
$('sa_btn_qlora_start')?.addEventListener('click', startQlora);
$('sa_btn_qlora_cancel')?.addEventListener('click', cancelQlora);
$('sa_btn_train_models_refresh')?.addEventListener('click', refreshTrainModels);
$('sa_btn_save_runner')?.addEventListener('click', saveRunner);
loadRunner();
setTrainMode('modelfile');
}
SA.training = {
render() {
wireTraining();
setTrainingTab(state.ttab);
},
async curateFromChat(messages, meta) {
try {
await SA.request('AssistentUpsertTrainSample', {
source: 'chat',
chat_id: meta?.chatId,
persona: meta?.persona,
pack: meta?.pack,
status: meta?.status || 'approved',
messages,
});
return true;
} catch (e) {
console.warn('curateFromChat', e);
return false;
}
},
setTrainingLock,
pollTrainJob,
};
}