Ship Assistent 0.13.0: real QLoRA pipeline and GGUF Ollama register.
Replace fake train loop with TRL SFTTrainer, HF column mapping with fiction preset, safetensors to GGUF conversion, and ollama create using ollama_base plus ADAPTER. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+116
-7
@@ -9168,6 +9168,7 @@ ${HELP_TEXT}`);
|
|||||||
hfResults: [],
|
hfResults: [],
|
||||||
hfSelected: null,
|
hfSelected: null,
|
||||||
hfCheck: null,
|
hfCheck: null,
|
||||||
|
hfMapping: null,
|
||||||
trainWs: null,
|
trainWs: null,
|
||||||
polling: null,
|
polling: null,
|
||||||
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
|
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
|
||||||
@@ -9242,7 +9243,10 @@ ${HELP_TEXT}`);
|
|||||||
refreshSamples();
|
refreshSamples();
|
||||||
loadAgentHeardSettings();
|
loadAgentHeardSettings();
|
||||||
}
|
}
|
||||||
if (state.ttab === "train") syncModelfileModels();
|
if (state.ttab === "train") {
|
||||||
|
syncModelfileModels();
|
||||||
|
syncQloraModels();
|
||||||
|
}
|
||||||
if (state.ttab === "models") refreshTrainModels();
|
if (state.ttab === "models") refreshTrainModels();
|
||||||
}
|
}
|
||||||
async function refreshSamples() {
|
async function refreshSamples() {
|
||||||
@@ -9303,6 +9307,78 @@ ${HELP_TEXT}`);
|
|||||||
await SA2.request("AssistentUpsertTrainSample", patch);
|
await SA2.request("AssistentUpsertTrainSample", patch);
|
||||||
await refreshSamples();
|
await refreshSamples();
|
||||||
}
|
}
|
||||||
|
function hfStringColumns(check) {
|
||||||
|
const cols = check?.schema?.columns;
|
||||||
|
if (Array.isArray(cols) && cols.length) return cols;
|
||||||
|
const feats = check?.features;
|
||||||
|
if (Array.isArray(feats)) {
|
||||||
|
return feats.map((f) => f?.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (feats && typeof feats === "object") return Object.keys(feats);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
function renderHfMappingUI(check) {
|
||||||
|
const row = $("sa_hf_mapping_row");
|
||||||
|
if (!row) return;
|
||||||
|
const gate = check?.gate;
|
||||||
|
const schemaKind = check?.schema?.kind;
|
||||||
|
const needsMapping = gate === "mapping" || schemaKind === "fiction_tags_text";
|
||||||
|
row.hidden = !needsMapping;
|
||||||
|
if (!needsMapping) {
|
||||||
|
state.hfMapping = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cols = hfStringColumns(check);
|
||||||
|
const userSel = $("sa_hf_user_col");
|
||||||
|
const asstSel = $("sa_hf_asst_col");
|
||||||
|
const presetSel = $("sa_hf_mapping_preset");
|
||||||
|
if (userSel) {
|
||||||
|
userSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join("");
|
||||||
|
if (cols.includes("tags")) userSel.value = "tags";
|
||||||
|
else if (cols.includes("title")) userSel.value = "title";
|
||||||
|
}
|
||||||
|
if (asstSel) {
|
||||||
|
asstSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join("");
|
||||||
|
if (cols.includes("text")) asstSel.value = "text";
|
||||||
|
else if (cols.includes("output")) asstSel.value = "output";
|
||||||
|
}
|
||||||
|
if (schemaKind === "fiction_tags_text" && presetSel) {
|
||||||
|
presetSel.value = "fiction_tags_text";
|
||||||
|
state.hfMapping = { kind: "fiction_tags_text", preset: "fiction_tags_text" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildHfMappingPayload() {
|
||||||
|
const preset = $("sa_hf_mapping_preset")?.value;
|
||||||
|
if (preset === "fiction_tags_text") {
|
||||||
|
return { kind: "fiction_tags_text", preset: "fiction_tags_text" };
|
||||||
|
}
|
||||||
|
const userCol = $("sa_hf_user_col")?.value;
|
||||||
|
const asstCol = $("sa_hf_asst_col")?.value;
|
||||||
|
if (userCol && asstCol) {
|
||||||
|
return { kind: "custom", user_col: userCol, assistant_col: asstCol };
|
||||||
|
}
|
||||||
|
return state.hfMapping;
|
||||||
|
}
|
||||||
|
async function syncQloraModels() {
|
||||||
|
try {
|
||||||
|
const baseUrl = $("sa_base_url")?.value || localStorage.getItem("swarm_assistent_base_url") || "";
|
||||||
|
const data = await SA2.request("AssistentListModels", { baseUrl });
|
||||||
|
const models = data?.models || [];
|
||||||
|
const sel = $("sa_qlora_ollama_base");
|
||||||
|
if (!sel) return;
|
||||||
|
const cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">\u2014</option>';
|
||||||
|
for (const m of models) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = m;
|
||||||
|
opt.textContent = m;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
else if ($("sa_model")?.value) sel.value = $("sa_model").value;
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
function renderHfList() {
|
function renderHfList() {
|
||||||
const root = $("sa_hf_list");
|
const root = $("sa_hf_list");
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -9353,6 +9429,7 @@ ${HELP_TEXT}`);
|
|||||||
}
|
}
|
||||||
const importRow = $("sa_hf_import_row");
|
const importRow = $("sa_hf_import_row");
|
||||||
if (importRow) importRow.hidden = data.gate === "rejected";
|
if (importRow) importRow.hidden = data.gate === "rejected";
|
||||||
|
renderHfMappingUI(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (status) status.textContent = String(e.message || e);
|
if (status) status.textContent = String(e.message || e);
|
||||||
}
|
}
|
||||||
@@ -9364,8 +9441,9 @@ ${HELP_TEXT}`);
|
|||||||
}
|
}
|
||||||
const id = state.hfSelected || state.hfCheck.id;
|
const id = state.hfSelected || state.hfCheck.id;
|
||||||
const limit = Number($("sa_hf_import_limit")?.value) || 200;
|
const limit = Number($("sa_hf_import_limit")?.value) || 200;
|
||||||
|
const mapping = buildHfMappingPayload();
|
||||||
try {
|
try {
|
||||||
const data = await SA2.request("AssistentImportHfDataset", { dataset: id, limit });
|
const data = await SA2.request("AssistentImportHfDataset", { dataset: id, limit, mapping });
|
||||||
setTrainStatus(`\u0418\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E: ${data.imported}${data.runner_only ? " (runner-only)" : ""}`);
|
setTrainStatus(`\u0418\u043C\u043F\u043E\u0440\u0442\u0438\u0440\u043E\u0432\u0430\u043D\u043E: ${data.imported}${data.runner_only ? " (runner-only)" : ""}`);
|
||||||
await refreshSamples();
|
await refreshSamples();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -9435,8 +9513,9 @@ ${HELP_TEXT}`);
|
|||||||
async function pollTrainJob() {
|
async function pollTrainJob() {
|
||||||
try {
|
try {
|
||||||
const data = await SA2.request("AssistentGetTrainJob", {});
|
const data = await SA2.request("AssistentGetTrainJob", {});
|
||||||
const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null;
|
const prog = data?.progress || (data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null);
|
||||||
const active = data?.training_active || data?.job?.status === "running";
|
const active = data?.training_active || data?.job?.status === "running";
|
||||||
|
const status = data?.job?.status || prog?.status;
|
||||||
setTrainingLock(active, prog?.status === "running" ? `\u0422\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430 \xB7 ${prog?.percent ?? 0}%` : "\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430\u2026");
|
setTrainingLock(active, prog?.status === "running" ? `\u0422\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430 \xB7 ${prog?.percent ?? 0}%` : "\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430\u2026");
|
||||||
const logEl = $("sa_train_log");
|
const logEl = $("sa_train_log");
|
||||||
const bar = $("sa_train_progress_fill");
|
const bar = $("sa_train_progress_fill");
|
||||||
@@ -9450,6 +9529,24 @@ ${HELP_TEXT}`);
|
|||||||
clearInterval(state.polling);
|
clearInterval(state.polling);
|
||||||
state.polling = null;
|
state.polling = null;
|
||||||
$("sa_btn_qlora_cancel").hidden = true;
|
$("sa_btn_qlora_cancel").hidden = true;
|
||||||
|
setTrainingLock(false);
|
||||||
|
if (status === "completed" || status === "completed_with_warnings") {
|
||||||
|
const ollama = prog?.ollama;
|
||||||
|
if (ollama?.success) {
|
||||||
|
setTrainStatus(`\u0413\u043E\u0442\u043E\u0432\u043E: \u043C\u043E\u0434\u0435\u043B\u044C ${ollama.name} \u0432 Ollama`);
|
||||||
|
SA2.app?.refreshModels?.();
|
||||||
|
} else if (ollama?.skipped) {
|
||||||
|
setTrainStatus(ollama.note || ollama.error || "\u0410\u0434\u0430\u043F\u0442\u0435\u0440 \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D, Ollama \u2014 \u0432\u0440\u0443\u0447\u043D\u0443\u044E");
|
||||||
|
} else if (ollama?.error) {
|
||||||
|
setTrainStatus(`\u041E\u0431\u0443\u0447\u0435\u043D\u0438\u0435 OK, Ollama: ${ollama.error}`);
|
||||||
|
} else if (status === "completed_with_warnings") {
|
||||||
|
setTrainStatus("\u041E\u0431\u0443\u0447\u0435\u043D\u0438\u0435 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E \u0441 \u043F\u0440\u0435\u0434\u0443\u043F\u0440\u0435\u0436\u0434\u0435\u043D\u0438\u044F\u043C\u0438 \u2014 \u0441\u043C. \u043B\u043E\u0433");
|
||||||
|
} else {
|
||||||
|
setTrainStatus("QLoRA \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043D\u043E");
|
||||||
|
}
|
||||||
|
} else if (status === "failed") {
|
||||||
|
setTrainStatus(`\u041E\u0448\u0438\u0431\u043A\u0430 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0438 (exit ${prog?.exit_code ?? "?"})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
@@ -9457,18 +9554,23 @@ ${HELP_TEXT}`);
|
|||||||
async function startQlora() {
|
async function startQlora() {
|
||||||
setTrainStatus("\u0417\u0430\u043F\u0443\u0441\u043A\u2026");
|
setTrainStatus("\u0417\u0430\u043F\u0443\u0441\u043A\u2026");
|
||||||
try {
|
try {
|
||||||
|
const hfDs = ($("sa_qlora_hf_dataset")?.value || "").trim();
|
||||||
|
const mapping = hfDs ? buildHfMappingPayload() : void 0;
|
||||||
await SA2.request("AssistentStartTrainJob", {
|
await SA2.request("AssistentStartTrainJob", {
|
||||||
base_url: $("sa_base_url")?.value,
|
base_url: $("sa_base_url")?.value,
|
||||||
chat_model: $("sa_model")?.value,
|
chat_model: $("sa_model")?.value,
|
||||||
base_model: $("sa_qlora_base")?.value,
|
base_model: $("sa_qlora_base")?.value,
|
||||||
|
ollama_base: $("sa_qlora_ollama_base")?.value,
|
||||||
output_name: $("sa_qlora_name")?.value,
|
output_name: $("sa_qlora_name")?.value,
|
||||||
rank: Number($("sa_qlora_rank")?.value) || 16,
|
rank: Number($("sa_qlora_rank")?.value) || 16,
|
||||||
alpha: Number($("sa_qlora_alpha")?.value) || 32,
|
alpha: Number($("sa_qlora_alpha")?.value) || 32,
|
||||||
lr: Number($("sa_qlora_lr")?.value) || 2e-4,
|
lr: Number($("sa_qlora_lr")?.value) || 2e-4,
|
||||||
epochs: Number($("sa_qlora_epochs")?.value) || 3,
|
epochs: Number($("sa_qlora_epochs")?.value) || 3,
|
||||||
seq_len: Number($("sa_qlora_seq")?.value) || 2048,
|
seq_len: Number($("sa_qlora_seq")?.value) || 2048,
|
||||||
|
max_samples: Number($("sa_qlora_max_samples")?.value) || 0,
|
||||||
four_bit: !!$("sa_qlora_4bit")?.checked,
|
four_bit: !!$("sa_qlora_4bit")?.checked,
|
||||||
hf_dataset: ($("sa_qlora_hf_dataset")?.value || "").trim() || void 0
|
hf_dataset: hfDs || void 0,
|
||||||
|
hf_mapping: mapping
|
||||||
});
|
});
|
||||||
$("sa_btn_qlora_cancel").hidden = false;
|
$("sa_btn_qlora_cancel").hidden = false;
|
||||||
setTrainingLock(true, "\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430\u2026");
|
setTrainingLock(true, "\u0418\u0434\u0451\u0442 \u0442\u0440\u0435\u043D\u0438\u0440\u043E\u0432\u043A\u0430\u2026");
|
||||||
@@ -9504,10 +9606,12 @@ ${HELP_TEXT}`);
|
|||||||
try {
|
try {
|
||||||
await SA2.request("AssistentSaveRunnerSettings", {
|
await SA2.request("AssistentSaveRunnerSettings", {
|
||||||
python: $("sa_runner_python")?.value,
|
python: $("sa_runner_python")?.value,
|
||||||
kind: $("sa_runner_kind")?.value,
|
kind: $("sa_runner_kind")?.value || "builtin",
|
||||||
workdir: $("sa_runner_workdir")?.value,
|
workdir: $("sa_runner_workdir")?.value,
|
||||||
cmd: $("sa_runner_cmd")?.value,
|
cmd: $("sa_runner_cmd")?.value,
|
||||||
gguf_script: $("sa_runner_gguf_script")?.value
|
gguf_script: $("sa_runner_gguf_script")?.value,
|
||||||
|
gguf_base_path: $("sa_runner_gguf_base")?.value,
|
||||||
|
gguf_cmd: $("sa_runner_gguf_cmd")?.value
|
||||||
});
|
});
|
||||||
setTrainStatus("\u0420\u0430\u043D\u043D\u0435\u0440 \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D");
|
setTrainStatus("\u0420\u0430\u043D\u043D\u0435\u0440 \u0441\u043E\u0445\u0440\u0430\u043D\u0451\u043D");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -9519,10 +9623,12 @@ ${HELP_TEXT}`);
|
|||||||
const data = await SA2.request("AssistentGetRunnerSettings", {});
|
const data = await SA2.request("AssistentGetRunnerSettings", {});
|
||||||
const s = data?.settings || {};
|
const s = data?.settings || {};
|
||||||
if ($("sa_runner_python") && s.python) $("sa_runner_python").value = s.python;
|
if ($("sa_runner_python") && s.python) $("sa_runner_python").value = s.python;
|
||||||
if ($("sa_runner_kind") && s.kind) $("sa_runner_kind").value = s.kind;
|
if ($("sa_runner_kind")) $("sa_runner_kind").value = s.kind || "builtin";
|
||||||
if ($("sa_runner_workdir") && s.workdir) $("sa_runner_workdir").value = s.workdir;
|
if ($("sa_runner_workdir") && s.workdir) $("sa_runner_workdir").value = s.workdir;
|
||||||
if ($("sa_runner_cmd") && s.cmd) $("sa_runner_cmd").value = s.cmd;
|
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_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) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9629,6 +9735,9 @@ ${HELP_TEXT}`);
|
|||||||
await checkHfLink();
|
await checkHfLink();
|
||||||
});
|
});
|
||||||
$("sa_btn_hf_check")?.addEventListener("click", checkHfLink);
|
$("sa_btn_hf_check")?.addEventListener("click", checkHfLink);
|
||||||
|
$("sa_hf_mapping_preset")?.addEventListener("change", () => {
|
||||||
|
state.hfMapping = buildHfMappingPayload();
|
||||||
|
});
|
||||||
$("sa_btn_hf_import")?.addEventListener("click", importHf);
|
$("sa_btn_hf_import")?.addEventListener("click", importHf);
|
||||||
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
|
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
|
||||||
r.addEventListener("change", () => setTrainMode(r.value));
|
r.addEventListener("change", () => setTrainMode(r.value));
|
||||||
|
|||||||
@@ -2281,6 +2281,21 @@
|
|||||||
width: 2.75rem;
|
width: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sa-hf-mapping-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sa-hf-mapping-row label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
.sa-hf-panel {
|
.sa-hf-panel {
|
||||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||||
border-radius: 0.45rem;
|
border-radius: 0.45rem;
|
||||||
|
|||||||
+149
-4
@@ -188,8 +188,14 @@ public partial class SwarmAssistentExtension
|
|||||||
return CacheHfCheck(cacheKey, result);
|
return CacheHfCheck(cacheKey, result);
|
||||||
}
|
}
|
||||||
JObject rowsData = JObject.Parse(rowsBody);
|
JObject rowsData = JObject.Parse(rowsBody);
|
||||||
JObject features = rowsData["features"] as JObject;
|
JObject features = FeaturesToObject(rowsData["features"]);
|
||||||
(string gate, string reason, JObject schema) = ClassifyHfFeatures(features);
|
(string gate, string reason, JObject schema) = ClassifyHfFeatures(features);
|
||||||
|
if (gate == "mapping" && TryFictionTagsTextPreset(features, out JObject presetSchema))
|
||||||
|
{
|
||||||
|
gate = "ok";
|
||||||
|
reason = "Fiction preset: title/tags → user, text → assistant";
|
||||||
|
schema = presetSchema;
|
||||||
|
}
|
||||||
result["gate"] = gate;
|
result["gate"] = gate;
|
||||||
result["reason"] = reason;
|
result["reason"] = reason;
|
||||||
result["schema"] = schema;
|
result["schema"] = schema;
|
||||||
@@ -197,7 +203,7 @@ public partial class SwarmAssistentExtension
|
|||||||
result["split"] = split;
|
result["split"] = split;
|
||||||
result["features"] = features;
|
result["features"] = features;
|
||||||
result["sample_rows"] = rowsData["rows"];
|
result["sample_rows"] = rowsData["rows"];
|
||||||
result["runner_only"] = HasHugeSizeTag(datasetId);
|
result["runner_only"] = await HasHugeSizeTagAsync(session, datasetId);
|
||||||
return CacheHfCheck(cacheKey, result);
|
return CacheHfCheck(cacheKey, result);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -207,7 +213,89 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool HasHugeSizeTag(string datasetId) => false;
|
static bool TryFictionTagsTextPreset(JObject features, out JObject schema)
|
||||||
|
{
|
||||||
|
schema = null;
|
||||||
|
if (features is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (JProperty p in features.Properties())
|
||||||
|
{
|
||||||
|
names.Add(p.Name);
|
||||||
|
}
|
||||||
|
if (!names.Contains("text") || (!names.Contains("tags") && !names.Contains("title")))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
schema = new JObject
|
||||||
|
{
|
||||||
|
["kind"] = "fiction_tags_text",
|
||||||
|
["assistant_col"] = "text",
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<bool> HasHugeSizeTagAsync(Session session, string datasetId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using HttpRequestMessage req = HfRequest($"{HfHubApi}/{Uri.EscapeDataString(datasetId)}", session);
|
||||||
|
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
JObject meta = JObject.Parse(await resp.Content.ReadAsStringAsync());
|
||||||
|
foreach (JToken t in meta["tags"] as JArray ?? [])
|
||||||
|
{
|
||||||
|
string tag = t?.ToString() ?? "";
|
||||||
|
if (tag.Contains("100K<", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tag.Contains("1M<", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tag.Contains("10M<", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| tag.Contains("100M<", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"HasHugeSizeTag {datasetId}: {ex.Message}");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static JObject ResolveHfMapping(JObject check, JObject mapping)
|
||||||
|
{
|
||||||
|
JObject schema = check?["schema"] as JObject;
|
||||||
|
string kind = schema?["kind"]?.ToString();
|
||||||
|
if (string.Equals(kind, "fiction_tags_text", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new JObject { ["kind"] = "fiction_tags_text", ["preset"] = "fiction_tags_text" };
|
||||||
|
}
|
||||||
|
if (mapping is not null && mapping.Count > 0)
|
||||||
|
{
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
if (string.Equals(mapping?["preset"]?.ToString(), "fiction_tags_text", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return new JObject { ["kind"] = "fiction_tags_text" };
|
||||||
|
}
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool MappingRequired(JObject check, JObject mapping)
|
||||||
|
{
|
||||||
|
string gate = check?["gate"]?.ToString();
|
||||||
|
if (gate != "mapping")
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
JObject resolved = ResolveHfMapping(check, mapping);
|
||||||
|
return resolved is null || !resolved.Properties().Any();
|
||||||
|
}
|
||||||
|
|
||||||
JObject CacheHfCheck(string cacheKey, JObject result)
|
JObject CacheHfCheck(string cacheKey, JObject result)
|
||||||
{
|
{
|
||||||
@@ -225,6 +313,33 @@ public partial class SwarmAssistentExtension
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static JObject FeaturesToObject(JToken tok)
|
||||||
|
{
|
||||||
|
if (tok is JObject obj)
|
||||||
|
{
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
if (tok is JArray arr)
|
||||||
|
{
|
||||||
|
JObject map = new();
|
||||||
|
foreach (JToken t in arr)
|
||||||
|
{
|
||||||
|
if (t is not JObject row)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string name = row["name"]?.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map[name] = row["type"] ?? row;
|
||||||
|
}
|
||||||
|
return map.Count > 0 ? map : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static (string gate, string reason, JObject schema) ClassifyHfFeatures(JObject features)
|
static (string gate, string reason, JObject schema) ClassifyHfFeatures(JObject features)
|
||||||
{
|
{
|
||||||
if (features is null || !features.Properties().Any())
|
if (features is null || !features.Properties().Any())
|
||||||
@@ -325,10 +440,11 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected" };
|
return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected" };
|
||||||
}
|
}
|
||||||
if (gate == "mapping" && (mapping is null || mapping.Count == 0))
|
if (MappingRequired(check, mapping))
|
||||||
{
|
{
|
||||||
return new JObject { ["error"] = "Нужен маппинг колонок", ["check"] = check };
|
return new JObject { ["error"] = "Нужен маппинг колонок", ["check"] = check };
|
||||||
}
|
}
|
||||||
|
mapping = ResolveHfMapping(check, mapping);
|
||||||
int take = Math.Clamp(limit, 1, 5000);
|
int take = Math.Clamp(limit, 1, 5000);
|
||||||
JArray rows = [];
|
JArray rows = [];
|
||||||
string config = check["config"]?.ToString() ?? "default";
|
string config = check["config"]?.ToString() ?? "default";
|
||||||
@@ -445,6 +561,35 @@ public partial class SwarmAssistentExtension
|
|||||||
new JObject { ["role"] = "assistant", ["content"] = row["answer"]?.ToString() ?? "" },
|
new JObject { ["role"] = "assistant", ["content"] = row["answer"]?.ToString() ?? "" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (kind == "fiction_tags_text")
|
||||||
|
{
|
||||||
|
string text = row["text"]?.ToString() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<string> userParts = [];
|
||||||
|
string title = row["title"]?.ToString()?.Trim();
|
||||||
|
string tags = row["tags"]?.ToString()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(title))
|
||||||
|
{
|
||||||
|
userParts.Add($"Title: {title}");
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(tags))
|
||||||
|
{
|
||||||
|
userParts.Add($"Tags: {tags}");
|
||||||
|
}
|
||||||
|
string user = userParts.Count > 0 ? string.Join("\n", userParts) : tags ?? title ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(user))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new JArray
|
||||||
|
{
|
||||||
|
new JObject { ["role"] = "user", ["content"] = user },
|
||||||
|
new JObject { ["role"] = "assistant", ["content"] = text },
|
||||||
|
};
|
||||||
|
}
|
||||||
if (kind == "custom" && mapping is not null)
|
if (kind == "custom" && mapping is not null)
|
||||||
{
|
{
|
||||||
string userCol = mapping["user_col"]?.ToString();
|
string userCol = mapping["user_col"]?.ToString();
|
||||||
|
|||||||
+10
-1
@@ -296,6 +296,7 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
List<JObject> samples = Memory.ListTrainSamples(status, null, null, 5000);
|
List<JObject> samples = Memory.ListTrainSamples(status, null, null, 5000);
|
||||||
StringBuilder sb = new();
|
StringBuilder sb = new();
|
||||||
|
int exported = 0;
|
||||||
foreach (JObject s in samples)
|
foreach (JObject s in samples)
|
||||||
{
|
{
|
||||||
JArray messages = s["messages"] as JArray ?? [];
|
JArray messages = s["messages"] as JArray ?? [];
|
||||||
@@ -303,6 +304,7 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
exported++;
|
||||||
if (string.Equals(format, "sharegpt", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(format, "sharegpt", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
JArray conv = [];
|
JArray conv = [];
|
||||||
@@ -328,7 +330,7 @@ public partial class SwarmAssistentExtension
|
|||||||
}
|
}
|
||||||
string path = Path.Combine(TrainingRoot(), "datasets", $"export_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.jsonl");
|
string path = Path.Combine(TrainingRoot(), "datasets", $"export_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.jsonl");
|
||||||
await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8);
|
await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8);
|
||||||
return new JObject { ["success"] = true, ["path"] = path, ["count"] = samples.Count, ["content"] = sb.ToString() };
|
return new JObject { ["success"] = true, ["path"] = path, ["count"] = exported, ["content"] = sb.ToString() };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -416,11 +418,18 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
JObject job = string.IsNullOrWhiteSpace(id) ? Memory.GetActiveTrainJob() : Memory.GetTrainJob(id);
|
JObject job = string.IsNullOrWhiteSpace(id) ? Memory.GetActiveTrainJob() : Memory.GetTrainJob(id);
|
||||||
|
if (job is not null && TrainingJobManager.IsRunning && string.Equals(job["id"]?.ToString(), TrainingJobManager.CurrentJobId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
JObject live = TrainingJobManager.GetProgress();
|
||||||
|
job["progress_json"] = live.ToString(Newtonsoft.Json.Formatting.None);
|
||||||
|
job["status"] = live["status"]?.ToString() ?? job["status"];
|
||||||
|
}
|
||||||
return new JObject
|
return new JObject
|
||||||
{
|
{
|
||||||
["success"] = true,
|
["success"] = true,
|
||||||
["job"] = job,
|
["job"] = job,
|
||||||
["training_active"] = TrainingJobManager.IsRunning,
|
["training_active"] = TrainingJobManager.IsRunning,
|
||||||
|
["progress"] = TrainingJobManager.IsRunning ? TrainingJobManager.GetProgress() : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+255
-67
@@ -35,30 +35,32 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
return new JObject { ["error"] = "base_model and output_name required" };
|
return new JObject { ["error"] = "base_model and output_name required" };
|
||||||
}
|
}
|
||||||
|
string hfDataset = null;
|
||||||
|
JObject hfCheck = null;
|
||||||
|
JObject hfMapping = raw["hf_mapping"] as JObject;
|
||||||
if (!string.IsNullOrWhiteSpace(raw["hf_dataset"]?.ToString()))
|
if (!string.IsNullOrWhiteSpace(raw["hf_dataset"]?.ToString()))
|
||||||
{
|
{
|
||||||
string dsId = NormalizeHfDatasetId(raw["hf_dataset"]?.ToString());
|
hfDataset = NormalizeHfDatasetId(raw["hf_dataset"]?.ToString());
|
||||||
if (dsId is null)
|
if (hfDataset is null)
|
||||||
{
|
{
|
||||||
return new JObject { ["error"] = "invalid hf_dataset id" };
|
return new JObject { ["error"] = "invalid hf_dataset id" };
|
||||||
}
|
}
|
||||||
JObject check = await CheckHfDatasetInternal(session, dsId, useCache: true);
|
hfCheck = await CheckHfDatasetInternal(session, hfDataset, useCache: true);
|
||||||
if (check["gate"]?.ToString() == "rejected")
|
if (hfCheck["gate"]?.ToString() == "rejected")
|
||||||
{
|
{
|
||||||
return new JObject { ["error"] = check["reason"]?.ToString() ?? "hf dataset rejected" };
|
return new JObject { ["error"] = hfCheck["reason"]?.ToString() ?? "hf dataset rejected" };
|
||||||
|
}
|
||||||
|
hfMapping = ResolveHfMapping(hfCheck, hfMapping);
|
||||||
|
if (MappingRequired(hfCheck, hfMapping))
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "Нужен маппинг колонок для HF набора", ["check"] = hfCheck };
|
||||||
}
|
}
|
||||||
raw["hf_dataset"] = dsId;
|
|
||||||
}
|
}
|
||||||
JObject runner = Config.LoadTrainingRunner();
|
JObject runner = Config.LoadTrainingRunner();
|
||||||
string python = runner["python"]?.ToString()?.Trim();
|
|
||||||
if (string.IsNullOrWhiteSpace(python))
|
|
||||||
{
|
|
||||||
python = "python";
|
|
||||||
}
|
|
||||||
string kind = runner["kind"]?.ToString()?.Trim();
|
string kind = runner["kind"]?.ToString()?.Trim();
|
||||||
if (string.IsNullOrWhiteSpace(kind))
|
if (string.IsNullOrWhiteSpace(kind))
|
||||||
{
|
{
|
||||||
return new JObject { ["error"] = "QLoRA-раннер не настроен (Настройки → Модели)" };
|
kind = "builtin";
|
||||||
}
|
}
|
||||||
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
|
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
|
||||||
string chatModel = raw["chat_model"]?.ToString()?.Trim();
|
string chatModel = raw["chat_model"]?.ToString()?.Trim();
|
||||||
@@ -66,11 +68,26 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
await AssistentParkLlm(session, baseUrl, chatModel);
|
await AssistentParkLlm(session, baseUrl, chatModel);
|
||||||
}
|
}
|
||||||
JObject export = await AssistentExportDataset(session, "approved", "jsonl");
|
string datasetPath = null;
|
||||||
string datasetPath = export["path"]?.ToString();
|
int exportCount = 0;
|
||||||
if (string.IsNullOrWhiteSpace(datasetPath) || !File.Exists(datasetPath))
|
if (string.IsNullOrWhiteSpace(hfDataset))
|
||||||
{
|
{
|
||||||
return new JObject { ["error"] = "Нет одобренных примеров для тренировки" };
|
JObject export = await AssistentExportDataset(session, "approved", "jsonl");
|
||||||
|
datasetPath = export["path"]?.ToString();
|
||||||
|
exportCount = export["count"]?.Value<int?>() ?? 0;
|
||||||
|
if (string.IsNullOrWhiteSpace(datasetPath) || !File.Exists(datasetPath) || exportCount <= 0)
|
||||||
|
{
|
||||||
|
return new JObject { ["error"] = "Нет одобренных примеров для тренировки (или укажи hf_dataset)" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
JObject export = await AssistentExportDataset(session, "approved", "jsonl");
|
||||||
|
exportCount = export["count"]?.Value<int?>() ?? 0;
|
||||||
|
if (exportCount > 0 && File.Exists(export["path"]?.ToString() ?? ""))
|
||||||
|
{
|
||||||
|
datasetPath = export["path"]?.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
string jobId = $"tj_{now}";
|
string jobId = $"tj_{now}";
|
||||||
@@ -78,19 +95,29 @@ public partial class SwarmAssistentExtension
|
|||||||
Directory.CreateDirectory(jobDir);
|
Directory.CreateDirectory(jobDir);
|
||||||
string configPath = Path.Combine(jobDir, "config.json");
|
string configPath = Path.Combine(jobDir, "config.json");
|
||||||
string logPath = Path.Combine(jobDir, "log.txt");
|
string logPath = Path.Combine(jobDir, "log.txt");
|
||||||
|
string adapterDir = Path.Combine(TrainingRoot(), "adapters", SanitizeAdapterName(outputName));
|
||||||
|
Directory.CreateDirectory(adapterDir);
|
||||||
JObject jobConfig = new()
|
JObject jobConfig = new()
|
||||||
{
|
{
|
||||||
["base_model"] = hfBase,
|
["base_model"] = hfBase,
|
||||||
["output_name"] = outputName,
|
["output_name"] = outputName,
|
||||||
|
["ollama_base"] = raw["ollama_base"]?.ToString()?.Trim(),
|
||||||
|
["gguf_base_path"] = raw["gguf_base_path"]?.ToString()?.Trim() ?? runner["gguf_base_path"]?.ToString()?.Trim(),
|
||||||
["dataset_path"] = datasetPath,
|
["dataset_path"] = datasetPath,
|
||||||
["hf_dataset"] = raw["hf_dataset"],
|
["hf_dataset"] = hfDataset,
|
||||||
|
["hf_mapping"] = hfMapping,
|
||||||
|
["hf_schema"] = hfCheck?["schema"],
|
||||||
|
["max_samples"] = raw["max_samples"] ?? 0,
|
||||||
["rank"] = raw["rank"] ?? 16,
|
["rank"] = raw["rank"] ?? 16,
|
||||||
["alpha"] = raw["alpha"] ?? 32,
|
["alpha"] = raw["alpha"] ?? 32,
|
||||||
["lr"] = raw["lr"] ?? 0.0002,
|
["lr"] = raw["lr"] ?? 0.0002,
|
||||||
["epochs"] = raw["epochs"] ?? 3,
|
["epochs"] = raw["epochs"] ?? 3,
|
||||||
["seq_len"] = raw["seq_len"] ?? 2048,
|
["seq_len"] = raw["seq_len"] ?? 2048,
|
||||||
["four_bit"] = raw["four_bit"] ?? true,
|
["four_bit"] = raw["four_bit"] ?? true,
|
||||||
["adapter_dir"] = Path.Combine(TrainingRoot(), "adapters", outputName),
|
["batch_size"] = raw["batch_size"] ?? 1,
|
||||||
|
["gradient_accumulation_steps"] = raw["gradient_accumulation_steps"] ?? 4,
|
||||||
|
["adapter_dir"] = adapterDir,
|
||||||
|
["local_export_count"] = exportCount,
|
||||||
};
|
};
|
||||||
await File.WriteAllTextAsync(configPath, jobConfig.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
await File.WriteAllTextAsync(configPath, jobConfig.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||||
Memory.SaveTrainJob(new JObject
|
Memory.SaveTrainJob(new JObject
|
||||||
@@ -114,6 +141,21 @@ public partial class SwarmAssistentExtension
|
|||||||
return new JObject { ["success"] = true, ["job_id"] = jobId, ["log_path"] = logPath };
|
return new JObject { ["success"] = true, ["job_id"] = jobId, ["log_path"] = logPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static string SanitizeAdapterName(string name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return "adapter";
|
||||||
|
}
|
||||||
|
char[] bad = Path.GetInvalidFileNameChars();
|
||||||
|
StringBuilder sb = new();
|
||||||
|
foreach (char c in name)
|
||||||
|
{
|
||||||
|
sb.Append(Array.IndexOf(bad, c) >= 0 ? '_' : c);
|
||||||
|
}
|
||||||
|
return sb.ToString().Trim();
|
||||||
|
}
|
||||||
|
|
||||||
static string BuildRunnerCommand(JObject runner, string configPath, string logPath, string workDir)
|
static string BuildRunnerCommand(JObject runner, string configPath, string logPath, string workDir)
|
||||||
{
|
{
|
||||||
string python = runner["python"]?.ToString()?.Trim();
|
string python = runner["python"]?.ToString()?.Trim();
|
||||||
@@ -121,10 +163,10 @@ public partial class SwarmAssistentExtension
|
|||||||
{
|
{
|
||||||
python = "python";
|
python = "python";
|
||||||
}
|
}
|
||||||
string kind = runner["kind"]?.ToString()?.Trim() ?? "custom";
|
string kind = runner["kind"]?.ToString()?.Trim() ?? "builtin";
|
||||||
string custom = runner["cmd"]?.ToString()?.Trim();
|
string custom = runner["cmd"]?.ToString()?.Trim();
|
||||||
string scriptPath = Path.Combine(FilePath, "scripts", "train_qlora.py");
|
string scriptPath = Path.Combine(FilePath, "scripts", "train_qlora.py");
|
||||||
if (kind == "custom" && !string.IsNullOrWhiteSpace(custom))
|
if (string.Equals(kind, "custom", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(custom))
|
||||||
{
|
{
|
||||||
return custom
|
return custom
|
||||||
.Replace("{python}", python, StringComparison.OrdinalIgnoreCase)
|
.Replace("{python}", python, StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -175,27 +217,38 @@ public partial class SwarmAssistentExtension
|
|||||||
return new JObject { ["success"] = true };
|
return new JObject { ["success"] = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
internal async Task FinishTrainJobAsync(string jobId, bool success, string logPath, Session session, string baseUrl, string chatModel, string adapterDir, string outputName, string ggufScript)
|
internal async Task FinishTrainJobAsync(
|
||||||
|
string jobId,
|
||||||
|
bool success,
|
||||||
|
string logPath,
|
||||||
|
Session session,
|
||||||
|
string baseUrl,
|
||||||
|
string chatModel,
|
||||||
|
JObject jobConfig,
|
||||||
|
JObject runner)
|
||||||
{
|
{
|
||||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
string adapterDir = jobConfig?["adapter_dir"]?.ToString() ?? "";
|
||||||
|
string outputName = jobConfig?["output_name"]?.ToString() ?? "";
|
||||||
|
JObject progress = TrainingJobManager.GetProgress();
|
||||||
|
string finalStatus = success ? "completed" : "failed";
|
||||||
|
if (success && Directory.Exists(adapterDir))
|
||||||
|
{
|
||||||
|
JObject reg = await RegisterAdapterPipeline(session, baseUrl, outputName, adapterDir, jobConfig, runner);
|
||||||
|
progress["ollama"] = reg;
|
||||||
|
if (reg["success"]?.Value<bool?>() != true && reg["skipped"]?.Value<bool?>() != true)
|
||||||
|
{
|
||||||
|
finalStatus = "completed_with_warnings";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progress["status"] = finalStatus;
|
||||||
Memory.SaveTrainJob(new JObject
|
Memory.SaveTrainJob(new JObject
|
||||||
{
|
{
|
||||||
["id"] = jobId,
|
["id"] = jobId,
|
||||||
["status"] = success ? "completed" : "failed",
|
["status"] = finalStatus,
|
||||||
["finished_at"] = now,
|
["finished_at"] = now,
|
||||||
["progress"] = TrainingJobManager.GetProgress(),
|
["progress"] = progress,
|
||||||
});
|
});
|
||||||
if (success && Directory.Exists(adapterDir))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await RegisterAdapterInOllama(session, baseUrl, outputName, adapterDir, ggufScript);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logs.Debug($"RegisterAdapter: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!string.IsNullOrWhiteSpace(chatModel))
|
if (!string.IsNullOrWhiteSpace(chatModel))
|
||||||
{
|
{
|
||||||
await AssistentWarmLlm(session, baseUrl, chatModel);
|
await AssistentWarmLlm(session, baseUrl, chatModel);
|
||||||
@@ -203,19 +256,115 @@ public partial class SwarmAssistentExtension
|
|||||||
TrainingJobManager.ClearRunning();
|
TrainingJobManager.ClearRunning();
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task RegisterAdapterInOllama(Session session, string baseUrl, string outputName, string adapterDir, string ggufScript)
|
async Task<JObject> RegisterAdapterPipeline(Session session, string baseUrl, string outputName, string adapterDir, JObject jobConfig, JObject runner)
|
||||||
{
|
{
|
||||||
string adapterFile = Directory.GetFiles(adapterDir, "*.gguf").FirstOrDefault()
|
string safetensors = Directory.GetFiles(adapterDir, "adapter_model.safetensors").FirstOrDefault();
|
||||||
?? Directory.GetFiles(adapterDir, "adapter_model.safetensors").FirstOrDefault();
|
if (string.IsNullOrWhiteSpace(safetensors))
|
||||||
if (string.IsNullOrWhiteSpace(adapterFile))
|
|
||||||
{
|
{
|
||||||
return;
|
return new JObject { ["success"] = false, ["error"] = "adapter_model.safetensors not found" };
|
||||||
}
|
}
|
||||||
|
string ggufPath = Directory.GetFiles(adapterDir, "*.gguf").FirstOrDefault();
|
||||||
|
string ggufScript = runner?["gguf_script"]?.ToString()?.Trim();
|
||||||
|
string ggufBase = jobConfig?["gguf_base_path"]?.ToString()?.Trim() ?? runner?["gguf_base_path"]?.ToString()?.Trim();
|
||||||
|
string python = runner?["python"]?.ToString()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(python))
|
||||||
|
{
|
||||||
|
python = "python";
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(ggufPath) && !string.IsNullOrWhiteSpace(ggufScript) && File.Exists(ggufScript))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ggufBase) || !File.Exists(ggufBase))
|
||||||
|
{
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = false,
|
||||||
|
["skipped"] = true,
|
||||||
|
["error"] = "gguf_base_path не задан или файл не найден — адаптер сохранён как safetensors",
|
||||||
|
["adapter_dir"] = adapterDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ggufPath = Path.Combine(adapterDir, "adapter.gguf");
|
||||||
|
string ggufCmd = runner?["gguf_cmd"]?.ToString()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(ggufCmd))
|
||||||
|
{
|
||||||
|
ggufCmd = "\"{python}\" \"{script}\" \"{base}\" \"{lora}\" \"{out}\"";
|
||||||
|
}
|
||||||
|
string cmd = ggufCmd
|
||||||
|
.Replace("{python}", python, StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{script}", ggufScript, StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{base}", ggufBase, StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{lora}", adapterDir, StringComparison.OrdinalIgnoreCase)
|
||||||
|
.Replace("{out}", ggufPath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
int code = await RunShellCommandAsync(cmd, adapterDir);
|
||||||
|
if (code != 0 || !File.Exists(ggufPath))
|
||||||
|
{
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = false,
|
||||||
|
["error"] = $"GGUF convert failed exit={code}",
|
||||||
|
["adapter_dir"] = adapterDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (string.IsNullOrWhiteSpace(ggufPath) || !File.Exists(ggufPath))
|
||||||
|
{
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = false,
|
||||||
|
["skipped"] = true,
|
||||||
|
["note"] = "Настрой convert_lora_to_gguf.py и gguf_base_path для регистрации в Ollama",
|
||||||
|
["adapter_dir"] = adapterDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
string ollamaBase = jobConfig?["ollama_base"]?.ToString()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(ollamaBase))
|
||||||
|
{
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = false,
|
||||||
|
["skipped"] = true,
|
||||||
|
["error"] = "ollama_base не задан — укажи базовую Ollama-модель на форме QLoRA",
|
||||||
|
["adapter_dir"] = adapterDir,
|
||||||
|
["gguf"] = ggufPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await RegisterAdapterInOllama(baseUrl, outputName, ollamaBase, ggufPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<int> RunShellCommandAsync(string commandLine, string workDir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ProcessStartInfo psi = new()
|
||||||
|
{
|
||||||
|
FileName = "cmd.exe",
|
||||||
|
Arguments = $"/c {commandLine}",
|
||||||
|
UseShellExecute = false,
|
||||||
|
RedirectStandardOutput = true,
|
||||||
|
RedirectStandardError = true,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WorkingDirectory = workDir ?? Environment.CurrentDirectory,
|
||||||
|
};
|
||||||
|
using Process proc = Process.Start(psi);
|
||||||
|
if (proc is null)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
await proc.WaitForExitAsync();
|
||||||
|
return proc.ExitCode;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Logs.Debug($"RunShellCommand: {ex.Message}");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async Task<JObject> RegisterAdapterInOllama(string baseUrl, string outputName, string ollamaBase, string adapterGguf)
|
||||||
|
{
|
||||||
StringBuilder mf = new();
|
StringBuilder mf = new();
|
||||||
JObject job = Memory.GetTrainJob(TrainingJobManager.CurrentJobId ?? "");
|
mf.AppendLine($"FROM {ollamaBase}");
|
||||||
string baseModel = job?["base_model"]?.ToString() ?? "unknown";
|
mf.AppendLine($"ADAPTER {adapterGguf.Replace("\\", "/")}");
|
||||||
mf.AppendLine($"FROM {baseModel}");
|
|
||||||
mf.AppendLine($"ADAPTER {adapterFile.Replace("\\", "/")}");
|
|
||||||
JObject payload = new()
|
JObject payload = new()
|
||||||
{
|
{
|
||||||
["name"] = outputName,
|
["name"] = outputName,
|
||||||
@@ -224,14 +373,31 @@ public partial class SwarmAssistentExtension
|
|||||||
};
|
};
|
||||||
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||||
using HttpResponseMessage resp = await HttpClient.PostAsync($"{NormalizeBaseUrl(baseUrl)}/api/create", content);
|
using HttpResponseMessage resp = await HttpClient.PostAsync($"{NormalizeBaseUrl(baseUrl)}/api/create", content);
|
||||||
_ = await resp.Content.ReadAsStringAsync();
|
string body = await resp.Content.ReadAsStringAsync();
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = false,
|
||||||
|
["error"] = $"ollama create HTTP {(int)resp.StatusCode}: {Clip(body, 400)}",
|
||||||
|
["modelfile"] = mf.ToString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new JObject
|
||||||
|
{
|
||||||
|
["success"] = true,
|
||||||
|
["name"] = outputName,
|
||||||
|
["ollama_base"] = ollamaBase,
|
||||||
|
["adapter"] = adapterGguf,
|
||||||
|
["response"] = body,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class TrainingJobManager
|
sealed class TrainingJobManager
|
||||||
{
|
{
|
||||||
static readonly Regex LossRe = new(@"loss[:\s]+([0-9.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
static readonly Regex LossRe = new(@"loss[:\s]+([0-9.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
static readonly Regex StepRe = new(@"(\d+)\s*/\s*(\d+)", RegexOptions.Compiled);
|
static readonly Regex StepRe = new(@"step\s+(\d+)\s*/\s*(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
Process _process;
|
Process _process;
|
||||||
readonly object _lock = new();
|
readonly object _lock = new();
|
||||||
@@ -242,6 +408,8 @@ sealed class TrainingJobManager
|
|||||||
string _jobId;
|
string _jobId;
|
||||||
string _baseUrl;
|
string _baseUrl;
|
||||||
string _chatModel;
|
string _chatModel;
|
||||||
|
long _lastProgressSaveMs;
|
||||||
|
int _lastSavedStep = -1;
|
||||||
|
|
||||||
public bool IsRunning { get; private set; }
|
public bool IsRunning { get; private set; }
|
||||||
public string CurrentJobId => _jobId;
|
public string CurrentJobId => _jobId;
|
||||||
@@ -260,6 +428,8 @@ sealed class TrainingJobManager
|
|||||||
_logPath = logPath;
|
_logPath = logPath;
|
||||||
_baseUrl = baseUrl;
|
_baseUrl = baseUrl;
|
||||||
_chatModel = chatModel;
|
_chatModel = chatModel;
|
||||||
|
_lastProgressSaveMs = 0;
|
||||||
|
_lastSavedStep = -1;
|
||||||
_progress = new JObject { ["status"] = "running", ["step"] = 0, ["loss"] = null, ["log"] = "" };
|
_progress = new JObject { ["status"] = "running", ["step"] = 0, ["loss"] = null, ["log"] = "" };
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -276,6 +446,7 @@ sealed class TrainingJobManager
|
|||||||
if (!string.IsNullOrWhiteSpace(hfToken))
|
if (!string.IsNullOrWhiteSpace(hfToken))
|
||||||
{
|
{
|
||||||
psi.Environment["HF_TOKEN"] = hfToken;
|
psi.Environment["HF_TOKEN"] = hfToken;
|
||||||
|
psi.Environment["HUGGING_FACE_HUB_TOKEN"] = hfToken;
|
||||||
}
|
}
|
||||||
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||||
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
|
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
|
||||||
@@ -313,7 +484,7 @@ sealed class TrainingJobManager
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
string prev = _progress["log"]?.ToString() ?? "";
|
string prev = _progress["log"]?.ToString() ?? "";
|
||||||
string combined = (prev + line + "\n");
|
string combined = prev + line + "\n";
|
||||||
if (combined.Length > 12000)
|
if (combined.Length > 12000)
|
||||||
{
|
{
|
||||||
combined = combined[^12000..];
|
combined = combined[^12000..];
|
||||||
@@ -327,33 +498,48 @@ sealed class TrainingJobManager
|
|||||||
Match stepM = StepRe.Match(line);
|
Match stepM = StepRe.Match(line);
|
||||||
if (stepM.Success)
|
if (stepM.Success)
|
||||||
{
|
{
|
||||||
_progress["step"] = int.Parse(stepM.Groups[1].Value);
|
|
||||||
_progress["total_steps"] = int.Parse(stepM.Groups[2].Value);
|
|
||||||
int total = int.Parse(stepM.Groups[2].Value);
|
|
||||||
int step = int.Parse(stepM.Groups[1].Value);
|
int step = int.Parse(stepM.Groups[1].Value);
|
||||||
|
int total = int.Parse(stepM.Groups[2].Value);
|
||||||
|
_progress["step"] = step;
|
||||||
|
_progress["total_steps"] = total;
|
||||||
_progress["percent"] = total > 0 ? (int)(100.0 * step / total) : 0;
|
_progress["percent"] = total > 0 ? (int)(100.0 * step / total) : 0;
|
||||||
}
|
}
|
||||||
try
|
MaybeSaveProgress(stepM.Success ? int.Parse(stepM.Groups[1].Value) : -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MaybeSaveProgress(int step)
|
||||||
|
{
|
||||||
|
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
|
bool stepChanged = step >= 0 && step != _lastSavedStep;
|
||||||
|
if (!stepChanged && now - _lastProgressSaveMs < 2500)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastProgressSaveMs = now;
|
||||||
|
if (step >= 0)
|
||||||
|
{
|
||||||
|
_lastSavedStep = step;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ext?.Memory?.SaveTrainJob(new JObject
|
||||||
{
|
{
|
||||||
_ext?.Memory?.SaveTrainJob(new JObject
|
["id"] = _jobId,
|
||||||
{
|
["status"] = "running",
|
||||||
["id"] = _jobId,
|
["progress"] = _progress,
|
||||||
["status"] = "running",
|
});
|
||||||
["progress"] = _progress,
|
}
|
||||||
});
|
catch
|
||||||
}
|
{
|
||||||
catch
|
// ignore
|
||||||
{
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async Task OnExited()
|
async Task OnExited()
|
||||||
{
|
{
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
string adapterDir = "";
|
JObject jobConfig = new();
|
||||||
string outputName = "";
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
ok = _process?.ExitCode == 0;
|
ok = _process?.ExitCode == 0;
|
||||||
@@ -366,16 +552,18 @@ sealed class TrainingJobManager
|
|||||||
JObject job = _ext.Memory.GetTrainJob(_jobId);
|
JObject job = _ext.Memory.GetTrainJob(_jobId);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
JObject cfg = JObject.Parse(job?["config_json"]?.ToString() ?? "{}");
|
string cfgRaw = job?["config_json"]?.ToString();
|
||||||
adapterDir = cfg["adapter_dir"]?.ToString() ?? "";
|
if (!string.IsNullOrWhiteSpace(cfgRaw))
|
||||||
outputName = cfg["output_name"]?.ToString() ?? job?["output_name"]?.ToString() ?? "";
|
{
|
||||||
|
jobConfig = JObject.Parse(cfgRaw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
JObject runner = _ext.Config.LoadTrainingRunner();
|
JObject runner = _ext.Config.LoadTrainingRunner();
|
||||||
await _ext.FinishTrainJobAsync(_jobId, ok, _logPath, _session, _baseUrl, _chatModel, adapterDir, outputName, runner["gguf_script"]?.ToString());
|
await _ext.FinishTrainJobAsync(_jobId, ok, _logPath, _session, _baseUrl, _chatModel, jobConfig, runner);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
|
|||||||
|
|
||||||
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
|
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
|
||||||
|
|
||||||
|
**Version 0.13.0** — **Реальный QLoRA-пайплайн**: `train_qlora.py` (TRL SFTTrainer + PEFT), HF-датасеты с маппингом (preset fiction title/tags→text), `max_samples`, полный post-train: safetensors → GGUF (`convert_lora_to_gguf.py`) → `ollama create` с `FROM ollama_base` + `ADAPTER`. Раннер: `builtin` + `custom`. Зависимости: `scripts/requirements-train.txt`.
|
||||||
|
|
||||||
**Version 0.12.1** — **Услышанное → агент**: одобренные примеры датасета сразу попадают в vector memory (`kind=heard`) и в контекст чата как `heard_examples` (без QLoRA). На вкладке «Датасет»: авто-подключение при одобрении, синхронизация всех, per-sample 🔗. Агент может запросить `heard_search`. Настройки: `training-agent.json`.
|
**Version 0.12.1** — **Услышанное → агент**: одобренные примеры датасета сразу попадают в vector memory (`kind=heard`) и в контекст чата как `heard_examples` (без QLoRA). На вкладке «Датасет»: авто-подключение при одобрении, синхронизация всех, per-sample 🔗. Агент может запросить `heard_search`. Настройки: `training-agent.json`.
|
||||||
|
|
||||||
**Version 0.12.0** — App-level tabs (Чат / Карточки / **Обучение** / Настройки), боковая панель истории чатов, вкладка обучения LLM: курирование диалогов, импорт JSONL/CSV, Hugging Face datasets (фильтр совместимости), быстрый Ollama Modelfile, опциональный QLoRA-раннер с локаутом VRAM. HF token из SwarmUI User Settings (`huggingface_api`).
|
**Version 0.12.0** — App-level tabs (Чат / Карточки / **Обучение** / Настройки), боковая панель истории чатов, вкладка обучения LLM: курирование диалогов, импорт JSONL/CSV, Hugging Face datasets (фильтр совместимости), быстрый Ollama Modelfile, опциональный QLoRA-раннер с локаутом VRAM. HF token из SwarmUI User Settings (`huggingface_api`).
|
||||||
@@ -217,6 +219,16 @@ Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client v
|
|||||||
| `AssistentGetDatasetAgentSettings` / `AssistentSaveDatasetAgentSettings` | «Услышанное» → agent RAG (`training-agent.json`) |
|
| `AssistentGetDatasetAgentSettings` / `AssistentSaveDatasetAgentSettings` | «Услышанное» → agent RAG (`training-agent.json`) |
|
||||||
| `AssistentLinkTrainSampleToAgent` / `AssistentUnlinkTrainSampleFromAgent` / `AssistentSyncDatasetToAgent` | Embed approved samples as `heard` memory |
|
| `AssistentLinkTrainSampleToAgent` / `AssistentUnlinkTrainSampleFromAgent` / `AssistentSyncDatasetToAgent` | Embed approved samples as `heard` memory |
|
||||||
|
|
||||||
|
## QLoRA setup (0.13.0)
|
||||||
|
|
||||||
|
1. Python env with CUDA: `pip install -r scripts/requirements-train.txt`
|
||||||
|
2. SwarmUI **User Settings** → `huggingface_api` (for HF base model download)
|
||||||
|
3. **Настройки → Модели**: runner kind = **builtin**, paths to `convert_lora_to_gguf.py` and **GGUF base** (same arch as HF base)
|
||||||
|
4. **Обучение → QLoRA**: HF base id, **Ollama base** (existing tag), output name, optional HF dataset (`krplt/ru-fictext-nsfw` auto-maps fiction preset)
|
||||||
|
5. Pipeline: train → `adapter_model.safetensors` → GGUF → `ollama create` with `ADAPTER`
|
||||||
|
|
||||||
|
Manual test checklist: small JSONL (5 pairs); HF dataset with `max_samples=50`; cancel job; missing deps (exit 2); missing gguf script (completed with note).
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
ExtensionAuthor = "mrleo1nid";
|
ExtensionAuthor = "mrleo1nid";
|
||||||
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
||||||
License = "MIT";
|
License = "MIT";
|
||||||
Version = "0.12.1";
|
Version = "0.13.0";
|
||||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard"];
|
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"];
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnInit()
|
public override void OnInit()
|
||||||
@@ -104,7 +104,7 @@ public partial class SwarmAssistentExtension : Extension
|
|||||||
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
|
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
|
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
|
||||||
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
|
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
|
||||||
Logs.Init("Swarm Assistent extension loaded (0.12.1 heard dataset → agent)");
|
Logs.Init("Swarm Assistent extension loaded (0.13.0 real QLoRA pipeline)");
|
||||||
}
|
}
|
||||||
|
|
||||||
int CfgInt(string key, int fallback)
|
int CfgInt(string key, int fallback)
|
||||||
|
|||||||
@@ -222,6 +222,17 @@
|
|||||||
<div class="sa-hf-status" id="sa_hf_status" role="status"></div>
|
<div class="sa-hf-status" id="sa_hf_status" role="status"></div>
|
||||||
<div class="sa-hf-list" id="sa_hf_list"></div>
|
<div class="sa-hf-list" id="sa_hf_list"></div>
|
||||||
<div class="sa-hf-preview" id="sa_hf_preview" hidden></div>
|
<div class="sa-hf-preview" id="sa_hf_preview" hidden></div>
|
||||||
|
<div class="sa-hf-mapping-row" id="sa_hf_mapping_row" hidden>
|
||||||
|
<span class="sa-settings-hint">Маппинг колонок:</span>
|
||||||
|
<label>Preset
|
||||||
|
<select id="sa_hf_mapping_preset" class="sa-select">
|
||||||
|
<option value="">— вручную —</option>
|
||||||
|
<option value="fiction_tags_text">Fiction: title/tags → text</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>User col <select id="sa_hf_user_col" class="sa-select"></select></label>
|
||||||
|
<label>Assistant col <select id="sa_hf_asst_col" class="sa-select"></select></label>
|
||||||
|
</div>
|
||||||
<div class="sa-hf-import-row" id="sa_hf_import_row" hidden>
|
<div class="sa-hf-import-row" id="sa_hf_import_row" hidden>
|
||||||
<label>Лимит строк <input type="number" id="sa_hf_import_limit" min="1" max="5000" value="200" /></label>
|
<label>Лимит строк <input type="number" id="sa_hf_import_limit" min="1" max="5000" value="200" /></label>
|
||||||
<label>Соотношение внешних:своих <input type="number" id="sa_hf_mix_ratio" min="0" max="20" step="0.5" value="3" title="Сколько внешних примеров на один свой" /></label>
|
<label>Соотношение внешних:своих <input type="number" id="sa_hf_mix_ratio" min="0" max="20" step="0.5" value="3" title="Сколько внешних примеров на один свой" /></label>
|
||||||
@@ -253,17 +264,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="sa-train-form" id="sa_train_form_qlora" hidden>
|
<div class="sa-train-form" id="sa_train_form_qlora" hidden>
|
||||||
<p class="sa-settings-hint">Base model — HF id (safetensors). Тренер скачает веса сам (нужен HF_TOKEN в User Settings).</p>
|
<p class="sa-settings-hint">Base model — HF id (safetensors). Тренер скачает веса сам (нужен HF_TOKEN в User Settings).</p>
|
||||||
<label>HF base model <input type="text" id="sa_qlora_base" placeholder="meta-llama/Llama-3.2-3B-Instruct" /></label>
|
<label>HF base model <input type="text" id="sa_qlora_base" placeholder="Qwen/Qwen2.5-7B-Instruct" /></label>
|
||||||
<label>Имя адаптера <input type="text" id="sa_qlora_name" placeholder="assistent-lora-v1" /></label>
|
<label>Ollama base (FROM для ADAPTER)
|
||||||
|
<select id="sa_qlora_ollama_base" class="sa-select"><option value="">—</option></select>
|
||||||
|
</label>
|
||||||
|
<label>Имя модели в Ollama <input type="text" id="sa_qlora_name" placeholder="my-lora:v1" /></label>
|
||||||
<div class="sa-settings-row sa-knob-row">
|
<div class="sa-settings-row sa-knob-row">
|
||||||
<label>rank <input type="number" id="sa_qlora_rank" min="4" max="128" value="16" /></label>
|
<label>rank <input type="number" id="sa_qlora_rank" min="4" max="128" value="16" /></label>
|
||||||
<label>alpha <input type="number" id="sa_qlora_alpha" min="4" max="256" value="32" /></label>
|
<label>alpha <input type="number" id="sa_qlora_alpha" min="4" max="256" value="32" /></label>
|
||||||
<label>LR <input type="number" id="sa_qlora_lr" min="0.000001" max="0.01" step="0.00001" value="0.0002" /></label>
|
<label>LR <input type="number" id="sa_qlora_lr" min="0.000001" max="0.01" step="0.00001" value="0.0002" /></label>
|
||||||
<label>epochs <input type="number" id="sa_qlora_epochs" min="1" max="20" value="3" /></label>
|
<label>epochs <input type="number" id="sa_qlora_epochs" min="1" max="20" value="3" /></label>
|
||||||
<label>seq_len <input type="number" id="sa_qlora_seq" min="512" max="8192" step="256" value="2048" /></label>
|
<label>seq_len <input type="number" id="sa_qlora_seq" min="512" max="8192" step="256" value="2048" /></label>
|
||||||
|
<label>max_samples <input type="number" id="sa_qlora_max_samples" min="0" max="50000" value="0" title="0 = все строки" /></label>
|
||||||
</div>
|
</div>
|
||||||
<label class="sa-check"><input type="checkbox" id="sa_qlora_4bit" checked /> 4-bit QLoRA</label>
|
<label class="sa-check"><input type="checkbox" id="sa_qlora_4bit" checked /> 4-bit QLoRA</label>
|
||||||
<label>HF датасет (опционально) <input type="text" id="sa_qlora_hf_dataset" placeholder="owner/name — только для раннера" /></label>
|
<label>HF датасет (опционально, без локального датасета) <input type="text" id="sa_qlora_hf_dataset" placeholder="krplt/ru-fictext-nsfw" /></label>
|
||||||
|
<p class="sa-settings-hint">После обучения: safetensors → GGUF (convert script) → ollama create. Настрой пути в Настройки → Модели.</p>
|
||||||
<button type="button" class="basic-button sa-primary" id="sa_btn_qlora_start">Запустить QLoRA</button>
|
<button type="button" class="basic-button sa-primary" id="sa_btn_qlora_start">Запустить QLoRA</button>
|
||||||
<button type="button" class="basic-button" id="sa_btn_qlora_cancel" hidden>Отменить</button>
|
<button type="button" class="basic-button" id="sa_btn_qlora_cancel" hidden>Отменить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,15 +339,15 @@
|
|||||||
<label>Python <input type="text" id="sa_runner_python" placeholder="python или полный путь" /></label>
|
<label>Python <input type="text" id="sa_runner_python" placeholder="python или полный путь" /></label>
|
||||||
<label>Тип тренера
|
<label>Тип тренера
|
||||||
<select id="sa_runner_kind" class="sa-select">
|
<select id="sa_runner_kind" class="sa-select">
|
||||||
<option value="">— не настроен —</option>
|
<option value="builtin" selected>Встроенный (train_qlora.py)</option>
|
||||||
<option value="llama-factory">LLaMA-Factory</option>
|
|
||||||
<option value="unsloth">Unsloth</option>
|
|
||||||
<option value="custom">Custom command</option>
|
<option value="custom">Custom command</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>GGUF base model (.gguf) <input type="text" id="sa_runner_gguf_base" placeholder="C:/models/base.gguf" title="Базовая GGUF для convert_lora_to_gguf" /></label>
|
||||||
<label>Рабочая директория <input type="text" id="sa_runner_workdir" placeholder="Assistent/training/runner" /></label>
|
<label>Рабочая директория <input type="text" id="sa_runner_workdir" placeholder="Assistent/training/runner" /></label>
|
||||||
<label>Custom command <input type="text" id="sa_runner_cmd" placeholder="{python} train.py --config {config}" /></label>
|
<label>Custom command <input type="text" id="sa_runner_cmd" placeholder="{python} train.py --config {config}" /></label>
|
||||||
<label>convert_lora_to_gguf.py <input type="text" id="sa_runner_gguf_script" placeholder="путь к convert_lora_to_gguf.py" /></label>
|
<label>convert_lora_to_gguf.py <input type="text" id="sa_runner_gguf_script" placeholder="C:/llama.cpp/convert_lora_to_gguf.py" /></label>
|
||||||
|
<label>GGUF cmd template <input type="text" id="sa_runner_gguf_cmd" placeholder='"{python}" "{script}" "{base}" "{lora}" "{out}"' /></label>
|
||||||
<div class="sa-settings-row">
|
<div class="sa-settings-row">
|
||||||
<button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button>
|
<button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button>
|
||||||
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
|
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Map Hugging Face dataset rows to chat messages for SFT."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def fiction_tags_text_user(row: dict, title_col: str = "title", tags_col: str = "tags") -> str:
|
||||||
|
parts = []
|
||||||
|
title = (row.get(title_col) or "").strip()
|
||||||
|
tags = (row.get(tags_col) or "").strip()
|
||||||
|
if title:
|
||||||
|
parts.append(f"Title: {title}")
|
||||||
|
if tags:
|
||||||
|
parts.append(f"Tags: {tags}")
|
||||||
|
return "\n".join(parts) if parts else tags or title or ""
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_messages(row: dict, schema: dict | None, mapping: dict | None) -> list[dict] | None:
|
||||||
|
schema = schema or {}
|
||||||
|
mapping = mapping or {}
|
||||||
|
kind = schema.get("kind") or mapping.get("kind") or mapping.get("preset")
|
||||||
|
|
||||||
|
if kind == "messages" and row.get("messages"):
|
||||||
|
return _normalize_messages(row["messages"])
|
||||||
|
|
||||||
|
if kind == "conversations" and row.get("conversations"):
|
||||||
|
out = []
|
||||||
|
for c in row["conversations"]:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
frm = c.get("from") or ""
|
||||||
|
val = c.get("value") or ""
|
||||||
|
role = "assistant" if frm in ("gpt", "assistant", "chatgpt") else "user"
|
||||||
|
if frm in ("human", "user"):
|
||||||
|
role = "user"
|
||||||
|
if val:
|
||||||
|
out.append({"role": role, "content": str(val)})
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
if kind == "alpaca":
|
||||||
|
instr = (row.get("instruction") or "").strip()
|
||||||
|
inp = (row.get("input") or "").strip()
|
||||||
|
output = (row.get("output") or "").strip()
|
||||||
|
user = instr if not inp else f"{instr}\n{inp}"
|
||||||
|
if user and output:
|
||||||
|
return [{"role": "user", "content": user}, {"role": "assistant", "content": output}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if kind == "prompt_response":
|
||||||
|
resp_col = schema.get("response_col") or "response"
|
||||||
|
prompt = row.get("prompt") or ""
|
||||||
|
resp = row.get(resp_col) or ""
|
||||||
|
if prompt and resp:
|
||||||
|
return [{"role": "user", "content": str(prompt)}, {"role": "assistant", "content": str(resp)}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if kind == "qa":
|
||||||
|
q = row.get("question") or ""
|
||||||
|
a = row.get("answer") or ""
|
||||||
|
if q and a:
|
||||||
|
return [{"role": "user", "content": str(q)}, {"role": "assistant", "content": str(a)}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if kind in ("fiction_tags_text", "preset_fiction_tags_text"):
|
||||||
|
text = (row.get("text") or "").strip()
|
||||||
|
user = fiction_tags_text_user(row)
|
||||||
|
if user and text:
|
||||||
|
return [{"role": "user", "content": user}, {"role": "assistant", "content": text}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if kind == "custom" or mapping.get("user_col"):
|
||||||
|
user_col = mapping.get("user_col")
|
||||||
|
asst_col = mapping.get("assistant_col")
|
||||||
|
if user_col and asst_col:
|
||||||
|
u = row.get(user_col) or ""
|
||||||
|
a = row.get(asst_col) or ""
|
||||||
|
if u and a:
|
||||||
|
return [{"role": "user", "content": str(u)}, {"role": "assistant", "content": str(a)}]
|
||||||
|
return None
|
||||||
|
|
||||||
|
if row.get("messages"):
|
||||||
|
return _normalize_messages(row["messages"])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_messages(msgs) -> list[dict] | None:
|
||||||
|
out = []
|
||||||
|
for m in msgs:
|
||||||
|
if not isinstance(m, dict):
|
||||||
|
continue
|
||||||
|
role = m.get("role") or "user"
|
||||||
|
content = m.get("content") or m.get("text") or ""
|
||||||
|
if content:
|
||||||
|
out.append({"role": role, "content": str(content)})
|
||||||
|
return out or None
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
torch>=2.1.0
|
||||||
|
transformers>=4.40.0
|
||||||
|
datasets>=2.18.0
|
||||||
|
peft>=0.10.0
|
||||||
|
bitsandbytes>=0.43.0
|
||||||
|
trl>=0.8.0
|
||||||
|
accelerate>=0.28.0
|
||||||
|
sentencepiece>=0.2.0
|
||||||
|
protobuf>=3.20.0
|
||||||
+198
-38
@@ -1,12 +1,16 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Minimal QLoRA trainer stub for Swarm Assistent.
|
"""QLoRA SFT trainer for Swarm Assistent."""
|
||||||
Requires: pip install torch transformers datasets peft bitsandbytes trl accelerate
|
from __future__ import annotations
|
||||||
Configure runner in Assistent settings or replace with LLaMA-Factory CLI."""
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import traceback
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from hf_dataset_map import row_to_messages
|
||||||
|
|
||||||
|
|
||||||
def log(msg, log_path):
|
def log(msg, log_path):
|
||||||
@@ -17,70 +21,226 @@ def log(msg, log_path):
|
|||||||
f.write(line + "\n")
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
class StepLogger:
|
||||||
|
def __init__(self, log_path, total_steps):
|
||||||
|
self.log_path = log_path
|
||||||
|
self.total_steps = max(1, total_steps)
|
||||||
|
|
||||||
|
def on_log(self, logs):
|
||||||
|
loss = logs.get("loss")
|
||||||
|
step = logs.get("step") or logs.get("global_step")
|
||||||
|
if step is None:
|
||||||
|
return
|
||||||
|
if loss is not None:
|
||||||
|
log(f"step {int(step)}/{self.total_steps} loss: {float(loss):.4f}", self.log_path)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_target_modules(model):
|
||||||
|
names = {n.split(".")[-1] for n, _ in model.named_modules()}
|
||||||
|
candidates = [
|
||||||
|
["q_proj", "k_proj", "v_proj", "o_proj"],
|
||||||
|
["q_proj", "v_proj"],
|
||||||
|
["Wqkv", "out_proj"],
|
||||||
|
["c_attn", "c_proj"],
|
||||||
|
]
|
||||||
|
for group in candidates:
|
||||||
|
if all(g in names for g in group):
|
||||||
|
return group
|
||||||
|
return ["q_proj", "v_proj"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_sft_dataset(cfg, log_path):
|
||||||
|
from datasets import Dataset, load_dataset
|
||||||
|
|
||||||
|
hf_dataset = cfg.get("hf_dataset")
|
||||||
|
dataset_path = cfg.get("dataset_path")
|
||||||
|
max_samples = int(cfg.get("max_samples") or 0)
|
||||||
|
hf_mapping = cfg.get("hf_mapping") or {}
|
||||||
|
schema = cfg.get("hf_schema") or {}
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
if hf_dataset:
|
||||||
|
log(f"loading HF dataset {hf_dataset}", log_path)
|
||||||
|
ds = load_dataset(hf_dataset, split="train")
|
||||||
|
if max_samples > 0:
|
||||||
|
ds = ds.select(range(min(len(ds), max_samples)))
|
||||||
|
for ex in ds:
|
||||||
|
msgs = row_to_messages(dict(ex), schema, hf_mapping)
|
||||||
|
if msgs:
|
||||||
|
rows.append({"messages": msgs})
|
||||||
|
elif dataset_path and os.path.isfile(dataset_path):
|
||||||
|
log(f"loading JSONL {dataset_path}", log_path)
|
||||||
|
with open(dataset_path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
msgs = obj.get("messages")
|
||||||
|
if msgs:
|
||||||
|
rows.append({"messages": msgs})
|
||||||
|
if max_samples > 0 and len(rows) >= max_samples:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
log("error: no dataset_path or hf_dataset", log_path)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
log("error: no training rows after mapping", log_path)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log(f"dataset rows: {len(rows)}", log_path)
|
||||||
|
return Dataset.from_list(rows)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--config", required=True)
|
p.add_argument("--config", required=True)
|
||||||
p.add_argument("--log", required=True)
|
p.add_argument("--log", required=True)
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
with open(args.config, encoding="utf-8") as f:
|
with open(args.config, encoding="utf-8") as f:
|
||||||
cfg = json.load(f)
|
cfg = json.load(f)
|
||||||
|
|
||||||
adapter_dir = cfg.get("adapter_dir", "adapter")
|
adapter_dir = cfg.get("adapter_dir", "adapter")
|
||||||
os.makedirs(adapter_dir, exist_ok=True)
|
os.makedirs(adapter_dir, exist_ok=True)
|
||||||
dataset_path = cfg.get("dataset_path")
|
|
||||||
hf_dataset = cfg.get("hf_dataset")
|
log(f"Swarm Assistent QLoRA starting base={cfg.get('base_model')}", args.log)
|
||||||
log(f"Swarm Assistent QLoRA stub starting base={cfg.get('base_model')}", args.log)
|
|
||||||
if not dataset_path and not hf_dataset:
|
|
||||||
log("error: no dataset", args.log)
|
|
||||||
sys.exit(1)
|
|
||||||
try:
|
try:
|
||||||
import torch
|
import torch
|
||||||
from datasets import load_dataset
|
from peft import LoraConfig, TaskType, get_peft_model
|
||||||
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
|
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainerCallback, TrainingArguments
|
||||||
from peft import LoraConfig, get_peft_model, TaskType
|
from trl import SFTTrainer
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
log(f"error: missing python deps ({e}). pip install torch transformers datasets peft bitsandbytes trl accelerate", args.log)
|
log(f"error: missing python deps ({e}). pip install -r scripts/requirements-train.txt", args.log)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
base = cfg.get("base_model")
|
base = cfg.get("base_model")
|
||||||
|
if not base:
|
||||||
|
log("error: base_model required", args.log)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ds = load_sft_dataset(cfg, args.log)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"error: dataset load failed: {e}", args.log)
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
four_bit = bool(cfg.get("four_bit", True))
|
||||||
|
seq_len = int(cfg.get("seq_len") or 2048)
|
||||||
|
rank = int(cfg.get("rank") or 16)
|
||||||
|
alpha = int(cfg.get("alpha") or 32)
|
||||||
|
lr = float(cfg.get("lr") or 2e-4)
|
||||||
|
epochs = int(cfg.get("epochs") or 3)
|
||||||
|
batch_size = int(cfg.get("batch_size") or 1)
|
||||||
|
grad_accum = int(cfg.get("gradient_accumulation_steps") or 4)
|
||||||
|
|
||||||
log(f"loading model {base}", args.log)
|
log(f"loading model {base}", args.log)
|
||||||
tokenizer = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
|
tokenizer = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
|
||||||
if tokenizer.pad_token is None:
|
if tokenizer.pad_token is None:
|
||||||
tokenizer.pad_token = tokenizer.eos_token
|
tokenizer.pad_token = tokenizer.eos_token
|
||||||
|
|
||||||
|
bnb_config = None
|
||||||
|
if four_bit:
|
||||||
|
bnb_config = BitsAndBytesConfig(
|
||||||
|
load_in_4bit=True,
|
||||||
|
bnb_4bit_quant_type="nf4",
|
||||||
|
bnb_4bit_compute_dtype=torch.float16,
|
||||||
|
bnb_4bit_use_double_quant=True,
|
||||||
|
)
|
||||||
|
|
||||||
model = AutoModelForCausalLM.from_pretrained(
|
model = AutoModelForCausalLM.from_pretrained(
|
||||||
base,
|
base,
|
||||||
load_in_4bit=bool(cfg.get("four_bit", True)),
|
quantization_config=bnb_config,
|
||||||
device_map="auto",
|
device_map="auto",
|
||||||
trust_remote_code=True,
|
trust_remote_code=True,
|
||||||
|
torch_dtype=torch.float16 if torch.cuda.is_available() else torch.float32,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_modules = detect_target_modules(model)
|
||||||
|
log(f"lora target_modules: {target_modules}", args.log)
|
||||||
|
|
||||||
lora = LoraConfig(
|
lora = LoraConfig(
|
||||||
r=int(cfg.get("rank", 16)),
|
r=rank,
|
||||||
lora_alpha=int(cfg.get("alpha", 32)),
|
lora_alpha=alpha,
|
||||||
|
lora_dropout=0.05,
|
||||||
task_type=TaskType.CAUSAL_LM,
|
task_type=TaskType.CAUSAL_LM,
|
||||||
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
|
target_modules=target_modules,
|
||||||
)
|
)
|
||||||
model = get_peft_model(model, lora)
|
model = get_peft_model(model, lora)
|
||||||
if hf_dataset:
|
|
||||||
ds = load_dataset(hf_dataset, split="train")
|
|
||||||
else:
|
|
||||||
ds = load_dataset("json", data_files=dataset_path, split="train")
|
|
||||||
|
|
||||||
def fmt(ex):
|
def formatting_func(examples):
|
||||||
msgs = ex.get("messages")
|
texts = []
|
||||||
if msgs:
|
for msgs in examples["messages"]:
|
||||||
text = tokenizer.apply_chat_template(msgs, tokenize=False)
|
try:
|
||||||
else:
|
text = tokenizer.apply_chat_template(msgs, tokenize=False, add_generation_prompt=False)
|
||||||
text = ex.get("text") or ""
|
except Exception:
|
||||||
return {"text": text}
|
parts = []
|
||||||
|
for m in msgs:
|
||||||
|
role = m.get("role", "user")
|
||||||
|
content = m.get("content", "")
|
||||||
|
parts.append(f"{role}: {content}")
|
||||||
|
text = "\n".join(parts)
|
||||||
|
texts.append(text)
|
||||||
|
return texts
|
||||||
|
|
||||||
|
total_steps = max(1, (len(ds) * epochs) // max(1, batch_size * grad_accum))
|
||||||
|
log(f"planned steps ~{total_steps}", args.log)
|
||||||
|
|
||||||
|
training_args = TrainingArguments(
|
||||||
|
output_dir=adapter_dir,
|
||||||
|
num_train_epochs=epochs,
|
||||||
|
per_device_train_batch_size=batch_size,
|
||||||
|
gradient_accumulation_steps=grad_accum,
|
||||||
|
learning_rate=lr,
|
||||||
|
logging_steps=1,
|
||||||
|
save_steps=max(50, total_steps // 10),
|
||||||
|
save_total_limit=2,
|
||||||
|
fp16=torch.cuda.is_available(),
|
||||||
|
bf16=False,
|
||||||
|
report_to="none",
|
||||||
|
remove_unused_columns=False,
|
||||||
|
max_grad_norm=0.3,
|
||||||
|
warmup_ratio=0.03,
|
||||||
|
lr_scheduler_type="cosine",
|
||||||
|
)
|
||||||
|
|
||||||
|
step_logger = StepLogger(args.log, total_steps)
|
||||||
|
|
||||||
|
class LossCallback(TrainerCallback):
|
||||||
|
def on_log(self, args_, state, control, logs=None, **kwargs):
|
||||||
|
if logs:
|
||||||
|
step_logger.on_log({**logs, "step": state.global_step})
|
||||||
|
|
||||||
|
try:
|
||||||
|
trainer = SFTTrainer(
|
||||||
|
model=model,
|
||||||
|
args=training_args,
|
||||||
|
train_dataset=ds,
|
||||||
|
tokenizer=tokenizer,
|
||||||
|
formatting_func=formatting_func,
|
||||||
|
max_seq_length=seq_len,
|
||||||
|
packing=False,
|
||||||
|
callbacks=[LossCallback()],
|
||||||
|
)
|
||||||
|
log("training started", args.log)
|
||||||
|
trainer.train()
|
||||||
|
trainer.save_model(adapter_dir)
|
||||||
|
tokenizer.save_pretrained(adapter_dir)
|
||||||
|
except Exception as e:
|
||||||
|
log(f"error: training failed: {e}", args.log)
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
ds = ds.map(fmt)
|
|
||||||
epochs = int(cfg.get("epochs", 3))
|
|
||||||
steps = max(1, min(len(ds), 100) * epochs)
|
|
||||||
for i in range(1, steps + 1):
|
|
||||||
log(f"step {i}/{steps} loss: {1.0 / i:.4f}", args.log)
|
|
||||||
time.sleep(0.05)
|
|
||||||
model.save_pretrained(adapter_dir)
|
|
||||||
tokenizer.save_pretrained(adapter_dir)
|
|
||||||
with open(os.path.join(adapter_dir, "train_done.json"), "w", encoding="utf-8") as f:
|
with open(os.path.join(adapter_dir, "train_done.json"), "w", encoding="utf-8") as f:
|
||||||
json.dump({"ok": True, "base": base}, f)
|
json.dump({"ok": True, "base": base, "rows": len(ds)}, f)
|
||||||
|
|
||||||
log("training complete", args.log)
|
log("training complete", args.log)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+118
-6
@@ -17,6 +17,7 @@ export function attachTraining(SA) {
|
|||||||
hfResults: [],
|
hfResults: [],
|
||||||
hfSelected: null,
|
hfSelected: null,
|
||||||
hfCheck: null,
|
hfCheck: null,
|
||||||
|
hfMapping: null,
|
||||||
trainWs: null,
|
trainWs: null,
|
||||||
polling: null,
|
polling: null,
|
||||||
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
|
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
|
||||||
@@ -97,7 +98,10 @@ export function attachTraining(SA) {
|
|||||||
refreshSamples();
|
refreshSamples();
|
||||||
loadAgentHeardSettings();
|
loadAgentHeardSettings();
|
||||||
}
|
}
|
||||||
if (state.ttab === 'train') syncModelfileModels();
|
if (state.ttab === 'train') {
|
||||||
|
syncModelfileModels();
|
||||||
|
syncQloraModels();
|
||||||
|
}
|
||||||
if (state.ttab === 'models') refreshTrainModels();
|
if (state.ttab === 'models') refreshTrainModels();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +166,81 @@ export function attachTraining(SA) {
|
|||||||
await refreshSamples();
|
await refreshSamples();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hfStringColumns(check) {
|
||||||
|
const cols = check?.schema?.columns;
|
||||||
|
if (Array.isArray(cols) && cols.length) return cols;
|
||||||
|
const feats = check?.features;
|
||||||
|
if (Array.isArray(feats)) {
|
||||||
|
return feats.map((f) => f?.name).filter(Boolean);
|
||||||
|
}
|
||||||
|
if (feats && typeof feats === 'object') return Object.keys(feats);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHfMappingUI(check) {
|
||||||
|
const row = $('sa_hf_mapping_row');
|
||||||
|
if (!row) return;
|
||||||
|
const gate = check?.gate;
|
||||||
|
const schemaKind = check?.schema?.kind;
|
||||||
|
const needsMapping = gate === 'mapping' || schemaKind === 'fiction_tags_text';
|
||||||
|
row.hidden = !needsMapping;
|
||||||
|
if (!needsMapping) {
|
||||||
|
state.hfMapping = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cols = hfStringColumns(check);
|
||||||
|
const userSel = $('sa_hf_user_col');
|
||||||
|
const asstSel = $('sa_hf_asst_col');
|
||||||
|
const presetSel = $('sa_hf_mapping_preset');
|
||||||
|
if (userSel) {
|
||||||
|
userSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
|
||||||
|
if (cols.includes('tags')) userSel.value = 'tags';
|
||||||
|
else if (cols.includes('title')) userSel.value = 'title';
|
||||||
|
}
|
||||||
|
if (asstSel) {
|
||||||
|
asstSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
|
||||||
|
if (cols.includes('text')) asstSel.value = 'text';
|
||||||
|
else if (cols.includes('output')) asstSel.value = 'output';
|
||||||
|
}
|
||||||
|
if (schemaKind === 'fiction_tags_text' && presetSel) {
|
||||||
|
presetSel.value = 'fiction_tags_text';
|
||||||
|
state.hfMapping = { kind: 'fiction_tags_text', preset: 'fiction_tags_text' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHfMappingPayload() {
|
||||||
|
const preset = $('sa_hf_mapping_preset')?.value;
|
||||||
|
if (preset === 'fiction_tags_text') {
|
||||||
|
return { kind: 'fiction_tags_text', preset: 'fiction_tags_text' };
|
||||||
|
}
|
||||||
|
const userCol = $('sa_hf_user_col')?.value;
|
||||||
|
const asstCol = $('sa_hf_asst_col')?.value;
|
||||||
|
if (userCol && asstCol) {
|
||||||
|
return { kind: 'custom', user_col: userCol, assistant_col: asstCol };
|
||||||
|
}
|
||||||
|
return state.hfMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncQloraModels() {
|
||||||
|
try {
|
||||||
|
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
|
||||||
|
const data = await SA.request('AssistentListModels', { baseUrl });
|
||||||
|
const models = data?.models || [];
|
||||||
|
const sel = $('sa_qlora_ollama_base');
|
||||||
|
if (!sel) return;
|
||||||
|
const cur = sel.value;
|
||||||
|
sel.innerHTML = '<option value="">—</option>';
|
||||||
|
for (const m of models) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m;
|
||||||
|
opt.textContent = m;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (cur) sel.value = cur;
|
||||||
|
else if ($('sa_model')?.value) sel.value = $('sa_model').value;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
function renderHfList() {
|
function renderHfList() {
|
||||||
const root = $('sa_hf_list');
|
const root = $('sa_hf_list');
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -216,6 +295,7 @@ export function attachTraining(SA) {
|
|||||||
}
|
}
|
||||||
const importRow = $('sa_hf_import_row');
|
const importRow = $('sa_hf_import_row');
|
||||||
if (importRow) importRow.hidden = data.gate === 'rejected';
|
if (importRow) importRow.hidden = data.gate === 'rejected';
|
||||||
|
renderHfMappingUI(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (status) status.textContent = String(e.message || e);
|
if (status) status.textContent = String(e.message || e);
|
||||||
}
|
}
|
||||||
@@ -228,8 +308,9 @@ export function attachTraining(SA) {
|
|||||||
}
|
}
|
||||||
const id = state.hfSelected || state.hfCheck.id;
|
const id = state.hfSelected || state.hfCheck.id;
|
||||||
const limit = Number($('sa_hf_import_limit')?.value) || 200;
|
const limit = Number($('sa_hf_import_limit')?.value) || 200;
|
||||||
|
const mapping = buildHfMappingPayload();
|
||||||
try {
|
try {
|
||||||
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit });
|
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping });
|
||||||
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
|
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
|
||||||
await refreshSamples();
|
await refreshSamples();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -303,8 +384,9 @@ export function attachTraining(SA) {
|
|||||||
async function pollTrainJob() {
|
async function pollTrainJob() {
|
||||||
try {
|
try {
|
||||||
const data = await SA.request('AssistentGetTrainJob', {});
|
const data = await SA.request('AssistentGetTrainJob', {});
|
||||||
const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null;
|
const prog = data?.progress || (data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null);
|
||||||
const active = data?.training_active || data?.job?.status === 'running';
|
const active = data?.training_active || data?.job?.status === 'running';
|
||||||
|
const status = data?.job?.status || prog?.status;
|
||||||
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…');
|
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…');
|
||||||
const logEl = $('sa_train_log');
|
const logEl = $('sa_train_log');
|
||||||
const bar = $('sa_train_progress_fill');
|
const bar = $('sa_train_progress_fill');
|
||||||
@@ -318,6 +400,24 @@ export function attachTraining(SA) {
|
|||||||
clearInterval(state.polling);
|
clearInterval(state.polling);
|
||||||
state.polling = null;
|
state.polling = null;
|
||||||
$('sa_btn_qlora_cancel').hidden = true;
|
$('sa_btn_qlora_cancel').hidden = true;
|
||||||
|
setTrainingLock(false);
|
||||||
|
if (status === 'completed' || status === 'completed_with_warnings') {
|
||||||
|
const ollama = prog?.ollama;
|
||||||
|
if (ollama?.success) {
|
||||||
|
setTrainStatus(`Готово: модель ${ollama.name} в Ollama`);
|
||||||
|
SA.app?.refreshModels?.();
|
||||||
|
} else if (ollama?.skipped) {
|
||||||
|
setTrainStatus(ollama.note || ollama.error || 'Адаптер сохранён, Ollama — вручную');
|
||||||
|
} else if (ollama?.error) {
|
||||||
|
setTrainStatus(`Обучение OK, Ollama: ${ollama.error}`);
|
||||||
|
} else if (status === 'completed_with_warnings') {
|
||||||
|
setTrainStatus('Обучение завершено с предупреждениями — см. лог');
|
||||||
|
} else {
|
||||||
|
setTrainStatus('QLoRA завершено');
|
||||||
|
}
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
setTrainStatus(`Ошибка тренировки (exit ${prog?.exit_code ?? '?'})`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
@@ -325,18 +425,23 @@ export function attachTraining(SA) {
|
|||||||
async function startQlora() {
|
async function startQlora() {
|
||||||
setTrainStatus('Запуск…');
|
setTrainStatus('Запуск…');
|
||||||
try {
|
try {
|
||||||
|
const hfDs = ($('sa_qlora_hf_dataset')?.value || '').trim();
|
||||||
|
const mapping = hfDs ? buildHfMappingPayload() : undefined;
|
||||||
await SA.request('AssistentStartTrainJob', {
|
await SA.request('AssistentStartTrainJob', {
|
||||||
base_url: $('sa_base_url')?.value,
|
base_url: $('sa_base_url')?.value,
|
||||||
chat_model: $('sa_model')?.value,
|
chat_model: $('sa_model')?.value,
|
||||||
base_model: $('sa_qlora_base')?.value,
|
base_model: $('sa_qlora_base')?.value,
|
||||||
|
ollama_base: $('sa_qlora_ollama_base')?.value,
|
||||||
output_name: $('sa_qlora_name')?.value,
|
output_name: $('sa_qlora_name')?.value,
|
||||||
rank: Number($('sa_qlora_rank')?.value) || 16,
|
rank: Number($('sa_qlora_rank')?.value) || 16,
|
||||||
alpha: Number($('sa_qlora_alpha')?.value) || 32,
|
alpha: Number($('sa_qlora_alpha')?.value) || 32,
|
||||||
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
|
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
|
||||||
epochs: Number($('sa_qlora_epochs')?.value) || 3,
|
epochs: Number($('sa_qlora_epochs')?.value) || 3,
|
||||||
seq_len: Number($('sa_qlora_seq')?.value) || 2048,
|
seq_len: Number($('sa_qlora_seq')?.value) || 2048,
|
||||||
|
max_samples: Number($('sa_qlora_max_samples')?.value) || 0,
|
||||||
four_bit: !!$('sa_qlora_4bit')?.checked,
|
four_bit: !!$('sa_qlora_4bit')?.checked,
|
||||||
hf_dataset: ($('sa_qlora_hf_dataset')?.value || '').trim() || undefined,
|
hf_dataset: hfDs || undefined,
|
||||||
|
hf_mapping: mapping,
|
||||||
});
|
});
|
||||||
$('sa_btn_qlora_cancel').hidden = false;
|
$('sa_btn_qlora_cancel').hidden = false;
|
||||||
setTrainingLock(true, 'Идёт тренировка…');
|
setTrainingLock(true, 'Идёт тренировка…');
|
||||||
@@ -377,10 +482,12 @@ export function attachTraining(SA) {
|
|||||||
try {
|
try {
|
||||||
await SA.request('AssistentSaveRunnerSettings', {
|
await SA.request('AssistentSaveRunnerSettings', {
|
||||||
python: $('sa_runner_python')?.value,
|
python: $('sa_runner_python')?.value,
|
||||||
kind: $('sa_runner_kind')?.value,
|
kind: $('sa_runner_kind')?.value || 'builtin',
|
||||||
workdir: $('sa_runner_workdir')?.value,
|
workdir: $('sa_runner_workdir')?.value,
|
||||||
cmd: $('sa_runner_cmd')?.value,
|
cmd: $('sa_runner_cmd')?.value,
|
||||||
gguf_script: $('sa_runner_gguf_script')?.value,
|
gguf_script: $('sa_runner_gguf_script')?.value,
|
||||||
|
gguf_base_path: $('sa_runner_gguf_base')?.value,
|
||||||
|
gguf_cmd: $('sa_runner_gguf_cmd')?.value,
|
||||||
});
|
});
|
||||||
setTrainStatus('Раннер сохранён');
|
setTrainStatus('Раннер сохранён');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -393,10 +500,12 @@ export function attachTraining(SA) {
|
|||||||
const data = await SA.request('AssistentGetRunnerSettings', {});
|
const data = await SA.request('AssistentGetRunnerSettings', {});
|
||||||
const s = data?.settings || {};
|
const s = data?.settings || {};
|
||||||
if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python;
|
if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python;
|
||||||
if ($('sa_runner_kind') && s.kind) $('sa_runner_kind').value = s.kind;
|
if ($('sa_runner_kind')) $('sa_runner_kind').value = s.kind || 'builtin';
|
||||||
if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir;
|
if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir;
|
||||||
if ($('sa_runner_cmd') && s.cmd) $('sa_runner_cmd').value = s.cmd;
|
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_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 */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +600,9 @@ export function attachTraining(SA) {
|
|||||||
await checkHfLink();
|
await checkHfLink();
|
||||||
});
|
});
|
||||||
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
|
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
|
||||||
|
$('sa_hf_mapping_preset')?.addEventListener('change', () => {
|
||||||
|
state.hfMapping = buildHfMappingPayload();
|
||||||
|
});
|
||||||
$('sa_btn_hf_import')?.addEventListener('click', importHf);
|
$('sa_btn_hf_import')?.addEventListener('click', importHf);
|
||||||
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
|
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
|
||||||
r.addEventListener('change', () => setTrainMode(r.value));
|
r.addEventListener('change', () => setTrainMode(r.value));
|
||||||
|
|||||||
Reference in New Issue
Block a user