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