Add Knowledge Hub books FTS and remove training UI (0.16.0).

Index Assistent/books search.jsonl, expose knowledge hops/catalog in chat, and drop QLoRA/HF training stack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 23:15:27 +03:00
co-authored by Cursor
parent baa75f2858
commit e1b5743795
25 changed files with 1611 additions and 5024 deletions
+92 -75
View File
@@ -5996,70 +5996,16 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
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 mountCurateButtons() {
// Training dataset curation removed in 0.16.
}
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 curateAssistantMessage() {
setStatus('Обучение снято — используй books/knowledge');
}
function isTrainingLocked() {
return !!state.trainingLock || document.getElementById('swarm_assistent_root')?.classList.contains('sa-root-training-lock');
return false;
}
function wantsAutoVision() {
@@ -6308,7 +6254,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
document.documentElement.style.setProperty('--sa-image-width', paneW);
}
if (view === 'cards') { view = 'chat'; }
if (view === 'chat' || view === 'settings' || view === 'train') {
if (view === 'chat' || view === 'settings') {
state.view = view;
}
const drawer = localStorage.getItem(LS_CHATS_DRAWER);
@@ -6375,7 +6321,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
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'; }
if (ui.view === 'chat' || ui.view === 'settings' || ui.view === 'train') {
if (ui.view === 'chat' || ui.view === 'settings') {
fill(LS_VIEW, ui.view, (v) => { state.view = v; });
}
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
@@ -6457,9 +6403,6 @@ 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);
}
@@ -7561,10 +7504,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
function loadPersonaPreview(id) {
const box = $('sa_persona_preview');
const knowPanel = $('sa_knowledge_panel');
if (!box || typeof genericRequest !== 'function') {
return;
}
box.innerHTML = '<div class="sa-mem-empty">Загрузка…</div>';
if (knowPanel) {
knowPanel.hidden = true;
}
genericRequest(
'AssistentGetPersonaShelves',
{ persona: id },
@@ -7573,6 +7520,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
const src = data?.source || '';
box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
syncPersonaPanelActions();
loadPersonaKnowledge(id);
},
0,
(err) => {
@@ -7581,6 +7529,75 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
);
}
function loadPersonaKnowledge(personaId) {
const panel = $('sa_knowledge_panel');
const list = $('sa_knowledge_list');
const saveBtn = $('sa_btn_knowledge_save');
const hint = $('sa_knowledge_hint');
if (!panel || !list || typeof genericRequest !== 'function') {
return;
}
list.innerHTML = '<div class="sa-mem-empty">Загрузка книг…</div>';
panel.hidden = false;
genericRequest(
'AssistentListKnowledgeCatalog',
{ persona: personaId },
(data) => {
const cat = data?.knowledge || {};
const attach = new Set((cat.attach || []).map(String));
const books = cat.books || [];
const editable = !!data?.editable;
if (hint) {
hint.textContent = editable
? 'Отметь книги для FTS-поиска (ask:knowledge). Сохраняется в overlay knowledge.json.'
: 'Bundled/pack — attach задан по умолчанию или assistent-pack.yaml. Клонируй в overlay для правок.';
}
if (saveBtn) {
saveBtn.hidden = !editable;
}
list.innerHTML = '';
if (!books.length) {
list.innerHTML = '<div class="sa-mem-empty">Книги не найдены на диске. gpu-rent seed-books + up.</div>';
return;
}
for (const b of books) {
const row = document.createElement('label');
row.className = 'sa-check sa-knowledge-row';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = b.id;
cb.checked = attach.has(String(b.id));
cb.disabled = !editable;
row.appendChild(cb);
const title = b.title || b.id;
const kind = b.content_kind ? ` · ${b.content_kind}` : '';
const idx = b.indexed ? '' : ' · нет search.jsonl';
row.appendChild(document.createTextNode(` ${title} (${b.id})${kind}${idx}`));
list.appendChild(row);
}
if (saveBtn && editable) {
saveBtn.onclick = () => {
const picked = [...list.querySelectorAll('input[type=checkbox]:checked')].map((el) => el.value);
genericRequest(
'AssistentSaveKnowledgeAttach',
{ persona: personaId, attach: picked },
() => {
setStatus('Knowledge attach сохранён');
loadPersonaKnowledge(personaId);
},
0,
(err) => setStatus(String(err || 'save failed')),
);
};
}
},
0,
(err) => {
list.innerHTML = `<div class="sa-mem-empty">${escapeHtml(String(err || 'ошибка'))}</div>`;
},
);
}
function exportSelectedPersona() {
const id = state.settingsPersonaId || $('sa_persona')?.value;
if (!id || typeof genericRequest !== 'function') {
@@ -7927,23 +7944,17 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
}
if (view === 'settings') {
state.view = 'settings';
} else if (view === 'train') {
state.view = 'train';
} else {
state.view = 'chat';
}
const chat = $('sa_view_chat');
const settings = $('sa_view_settings');
const train = $('sa_view_train');
if (chat) {
chat.hidden = state.view !== 'chat';
}
if (settings) {
settings.hidden = state.view !== 'settings';
}
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);
@@ -7951,12 +7962,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
};
tabActive('sa_tab_chat', state.view === 'chat');
tabActive('sa_tab_settings', state.view === 'settings');
tabActive('sa_tab_train', state.view === 'train');
saveSettings();
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 });
@@ -9222,6 +9230,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
});
}
}
if (meta.knowledge?.hops?.length) {
const hopLabels = meta.knowledge.hops.map((h) => `${h.tool || 'hop'}:${h.count ?? '?'}`).slice(0, 6);
activityDone('knowledge', {
kind: 'ask',
label: 'knowledge hops',
detail: hopLabels.join(' · '),
});
}
captureActivityTrace();
state.history.push({
role: 'assistant',
@@ -9339,6 +9355,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
system_chars: data.system_chars,
system_layers: data.system_layers,
prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count,
knowledge: data.knowledge,
});
}
},
@@ -9371,6 +9388,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
system_chars: data.system_chars,
system_layers: data.system_layers,
prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count,
knowledge: data.knowledge,
});
},
0,
@@ -9398,6 +9416,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
system_chars: data.system_chars,
system_layers: data.system_layers,
prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count,
knowledge: data.knowledge,
});
},
0,
@@ -9600,7 +9619,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
window.__swarmAssistentWired = true;
loadSettings();
setView(state.view || 'chat');
void window.SA?.training?.resumePolling?.();
updateGate();
ensureBoard();
setBoardTab(state.boardTab || 'generate', { persist: false });
@@ -9699,7 +9717,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('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'));
-3
View File
@@ -29,6 +29,3 @@ window.SA.applyConfigPatchKeys = function (config) {
};
import './app.js';
import { attachTraining } from './training.js';
attachTraining(window.SA);
-942
View File
@@ -1,942 +0,0 @@
/** 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;');
}
const QLORA_HF_CUSTOM = '__custom__';
export function attachTraining(SA) {
const state = {
ttab: 'dataset',
samples: [],
hfResults: [],
hfSelected: null,
hfCheck: null,
hfMapping: null,
trainWs: null,
polling: null,
qloraTraining: 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) {
const msg = String(e.message || e);
setTrainStatus(formatTrainError(msg));
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(formatTrainError(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(formatTrainError(e.message || e));
}
}
function setTrainStatus(msg) {
const el = $('sa_train_status');
if (el) el.textContent = msg || '';
}
function isSqliteError(msg) {
const s = String(msg || '').toLowerCase();
return s.includes('sqlite')
|| s.includes('sqlconnection')
|| s.includes('train_samples')
|| s.includes('training database unavailable');
}
function isGenericServerError(msg) {
return /internal error occurred/i.test(String(msg || ''));
}
function formatTrainError(msg) {
const s = String(msg || '');
if (isSqliteError(s)) {
return `${s}${sqliteHint()}`;
}
if (isGenericServerError(s)) {
return `${s} — часто SQLite/импорт HF: gpu-rent seed-extensions, restart SwarmUI, HF token для gated.`;
}
return s;
}
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) => {
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();
syncQloraModels();
void resumeTrainJobPolling();
}
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 });
if (data?.error) {
const msg = data.error;
setTrainStatus(formatTrainError(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) {
const appr = data?.approved ?? '—';
const draft = data?.draft ?? '—';
const total = data?.total ?? '—';
stats.textContent = `Одобрено: ${appr} · черновики: ${draft} · всего: ${total}`;
}
const personaSel = $('sa_train_filter_persona');
if (personaSel && $('sa_persona')) {
const cur = personaSel.value || 'all';
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(formatTrainError(e.message || e));
}
}
function renderSamples() {
const root = $('sa_train_samples');
if (!root) return;
const filter = $('sa_train_filter_status')?.value || 'all';
if (!state.samples.length) {
const hint = filter === 'approved'
? 'Под фильтром «Одобренные» пусто. HF-импорт создаёт <strong>черновики</strong> — переключи на «Черновики» или «Все статусы».'
: filter === 'draft'
? 'Нет черновиков. Импортируй HF или отметь примеры в чате.'
: 'Нет примеров. Отметь ответы в чате или импортируй датасет (HF / файл).';
root.innerHTML = `<div class="sa-mem-empty">${hint}</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 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;
}
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 || [];
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;
applyQloraPresetFromSelect({ fillName: false });
} catch (e) { /* ignore */ }
}
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(formatTrainError(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';
renderHfMappingUI(data);
} catch (e) {
if (status) status.textContent = String(e.message || e);
}
}
async function importHf() {
const link = ($('sa_hf_link')?.value || '').trim();
if (!state.hfCheck?.id && link) {
await checkHfLink();
}
if (!state.hfSelected && !state.hfCheck?.id) {
setTrainStatus('Сначала проверь набор');
const hfSt = $('sa_hf_status');
if (hfSt) hfSt.textContent = 'Вставь ссылку и нажми «Проверить»';
return;
}
const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200;
const mapping = buildHfMappingPayload();
const btn = $('sa_btn_hf_import');
const hfSt = $('sa_hf_status');
const busy = limit > 400
? `Импортирую до ${limit} строк… (12 мин)`
: `Импортирую до ${limit}`;
setTrainStatus(busy);
if (hfSt) {
hfSt.textContent = busy;
hfSt.classList.add('sa-hf-busy');
}
if (btn) {
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = '…';
}
try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping });
const n = Number(data?.imported) || 0;
let msg;
if (data?.runner_only) {
msg = `Runner-only: ${data.note || id} (в sqlite не импортировали)`;
} else if (n > 0) {
const filt = $('sa_train_filter_status');
if (filt && filt.value === 'approved') {
filt.value = 'draft';
}
msg = `Импортировано: ${n} черновик(ов) — список ниже (фильтр → черновики)`;
} else {
msg = 'Импортировано: 0 — HF token в User Settings, маппинг или gated-набор';
}
setTrainStatus(msg);
if (hfSt) hfSt.textContent = msg;
await refreshSamples();
$('sa_train_samples')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
const err = String(e.message || e);
const show = formatTrainError(err);
setTrainStatus(show);
if (hfSt) hfSt.textContent = show;
} finally {
if (hfSt) hfSt.classList.remove('sa-hf-busy');
if (btn) {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Импортировать';
}
}
}
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() {
const btn = $('sa_btn_modelfile_create');
btn?.setAttribute('disabled', 'disabled');
setTrainStatus('Создаю Modelfile в Ollama…');
try {
const data = await SA.request('AssistentCreateOllamaModel', {
base_url: $('sa_base_url')?.value,
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(formatTrainError(e.message || e));
} finally {
btn?.removeAttribute('disabled');
}
}
function setTrainMode(mode) {
$('sa_train_form_modelfile').hidden = mode !== 'modelfile';
$('sa_train_form_qlora').hidden = mode !== 'qlora';
const radio = document.querySelector(`input[name="sa_train_mode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
function setTrainingLock(on, text) {
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?.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;
const pct = prog?.percent;
const bannerText = active && pct != null
? `QLoRA · ${pct}%`
: active
? 'Идёт QLoRA…'
: 'Идёт тренировка…';
setTrainingLock(active, bannerText);
const logEl = $('sa_train_log');
const bar = $('sa_train_progress_fill');
const box = $('sa_train_progress');
if (prog) {
if (box) box.hidden = false;
if (bar && pct != null) bar.style.width = `${pct}%`;
if (logEl && prog.log) logEl.textContent = prog.log;
}
if (active) {
setTrainStatus(pct != null
? `QLoRA · ${pct}% — полный лог на вкладке «Тренировка»`
: 'QLoRA запущена — полный лог на вкладке «Тренировка»');
}
if (!active) {
clearInterval(state.polling);
state.polling = null;
$('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 || 'Адаптер сохранён — см. вкладку «Модели»');
} else if (ollama?.error) {
setTrainStatus(`Обучение OK, Ollama: ${ollama.error}`);
} else if (status === 'completed_with_warnings') {
setTrainStatus('Завершено с предупреждениями — лог на «Модели»');
} else {
setTrainStatus('QLoRA завершено — вкладка «Модели»');
}
setTrainingTab('models');
await refreshTrainModels();
} else if (status === 'failed') {
setTrainStatus(`Ошибка тренировки (exit ${prog?.exit_code ?? '?'})`);
}
}
} catch (e) { /* ignore */ }
}
async function resumeTrainJobPolling() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const active = data?.training_active || data?.job?.status === 'running';
await pollTrainJob();
if (!active) return;
setTrainMode('qlora');
$('sa_btn_qlora_cancel').hidden = false;
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
} catch (e) { /* ignore */ }
}
async function startQlora() {
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();
const mapping = hfDs ? buildHfMappingPayload() : undefined;
await SA.request('AssistentStartTrainJob', {
base_url: $('sa_base_url')?.value,
chat_model: $('sa_model')?.value,
base_model: baseModel,
ollama_base: $('sa_qlora_ollama_base')?.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,
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: hfDs || undefined,
hf_mapping: mapping,
});
$('sa_btn_qlora_cancel').hidden = false;
setTrainingLock(true, 'Идёт тренировка…');
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
pollTrainJob();
setTrainMode('qlora');
setTrainingTab('train');
setTrainStatus('QLoRA запущена — прогресс ниже');
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function cancelQlora() {
try {
await SA.request('AssistentCancelTrainJob', {});
setTrainingLock(false);
setTrainStatus('Отменено');
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function refreshTrainModels() {
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) => {
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) {
const msg = String(e.message || e);
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(isSqliteError(msg) ? sqliteHint() : msg)}</div>`;
}
}
async function saveRunner() {
try {
await SA.request('AssistentSaveRunnerSettings', {
python: $('sa_runner_python')?.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) {
setTrainStatus(formatTrainError(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')) $('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 */ }
}
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(formatTrainError(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(formatTrainError(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(formatTrainError(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_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));
});
$('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);
$('sa_btn_save_runner')?.addEventListener('click', saveRunner);
loadRunner();
setTrainMode('modelfile');
void resumeTrainJobPolling();
}
SA.training = {
render() {
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 {
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,
};
}