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:
+92
-75
@@ -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'));
|
||||
|
||||
@@ -29,6 +29,3 @@ window.SA.applyConfigPatchKeys = function (config) {
|
||||
};
|
||||
|
||||
import './app.js';
|
||||
import { attachTraining } from './training.js';
|
||||
|
||||
attachTraining(window.SA);
|
||||
|
||||
-942
@@ -1,942 +0,0 @@
|
||||
/** Swarm Assistent — training tab (dataset, Modelfile, QLoRA). */
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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} строк… (1–2 мин)`
|
||||
: `Импортирую до ${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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user