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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 23:15:27 +03:00
co-authored by Cursor
parent baa75f2858
commit e1b5743795
25 changed files with 1611 additions and 5024 deletions
+480 -1359
View File
File diff suppressed because it is too large Load Diff
+46 -4
View File
@@ -98,6 +98,11 @@ public partial class SwarmAssistentExtension
} }
AddLayer("skills", skillsBlock.ToString()); AddLayer("skills", skillsBlock.ToString());
AddLayer("identity", Config.RenderIdentityBlock(pid)); AddLayer("identity", Config.RenderIdentityBlock(pid));
string knowledgeLayer = BuildKnowledgeSystemLayer(pid);
if (!string.IsNullOrWhiteSpace(knowledgeLayer))
{
AddLayer("knowledge", knowledgeLayer);
}
} }
if (!string.IsNullOrWhiteSpace(packName)) if (!string.IsNullOrWhiteSpace(packName))
@@ -179,7 +184,7 @@ public partial class SwarmAssistentExtension
return (ollamaMessages, layers); return (ollamaMessages, layers);
} }
async Task<(string reply, JObject raw, JArray civitaiResults, int systemChars, JObject systemLayers)> RunChatWithHops( async Task<(string reply, JObject raw, JArray civitaiResults, JObject knowledge, int systemChars, JObject systemLayers)> RunChatWithHops(
Session session, Session session,
string root, string root,
string modelName, string modelName,
@@ -238,6 +243,8 @@ public partial class SwarmAssistentExtension
?? 0; ?? 0;
string reply = ""; string reply = "";
JObject lastRaw = null; JObject lastRaw = null;
JArray knowledgeHops = [];
JArray knowledgeResults = [];
int maxHops = slimUtility ? 1 : CfgInt("max_tool_hops", MaxToolHopsFallback); int maxHops = slimUtility ? 1 : CfgInt("max_tool_hops", MaxToolHopsFallback);
HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase); HashSet<string> hopDone = new(StringComparer.OrdinalIgnoreCase);
for (int hop = 0; hop < maxHops; hop++) for (int hop = 0; hop < maxHops; hop++)
@@ -267,7 +274,7 @@ public partial class SwarmAssistentExtension
follow = null; follow = null;
break; break;
} }
follow = await RunToolHop(session, pid, patch, tool, hopDone); follow = await RunToolHop(session, pid, patch, tool, hopDone, knowledgeHops, knowledgeResults);
if (follow is not null) if (follow is not null)
{ {
break; break;
@@ -285,7 +292,9 @@ public partial class SwarmAssistentExtension
messages.Add(new JObject { ["role"] = "assistant", ["content"] = assistantContent }); messages.Add(new JObject { ["role"] = "assistant", ["content"] = assistantContent });
messages.Add(new JObject { ["role"] = "user", ["content"] = follow }); messages.Add(new JObject { ["role"] = "user", ["content"] = follow });
} }
return (reply, lastRaw, [], systemChars, systemLayers); JObject knowledge = BuildKnowledgeResponse(pid, knowledgeHops, knowledgeResults);
JArray civitai = CivitaiResultsShim(knowledgeResults);
return (reply, lastRaw, civitai, knowledge, systemChars, systemLayers);
} }
static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null) static string BuildRetrieveQuery(JArray userMessages, string contextJson, string packName = null)
@@ -413,7 +422,9 @@ public partial class SwarmAssistentExtension
string pid, string pid,
JObject patch, JObject patch,
string tool, string tool,
HashSet<string> hopDone) HashSet<string> hopDone,
JArray knowledgeHops = null,
JArray knowledgeResults = null)
{ {
if (tool == "ask_settings") if (tool == "ask_settings")
{ {
@@ -452,6 +463,35 @@ public partial class SwarmAssistentExtension
+ "omit ask:inventory unless you need a different query.\n```json\n" + "omit ask:inventory unless you need a different query.\n```json\n"
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```"; + rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
} }
if (tool == "ask_knowledge")
{
string q = patch?["knowledge_query"]?.ToString()?.Trim()
?? patch?["example_query"]?.ToString()?.Trim()
?? patch?["memory_query"]?.ToString()?.Trim()
?? "";
string rating = patch?["knowledge_rating"]?.ToString()?.Trim()
?? patch?["example_rating"]?.ToString()?.Trim();
string sig = "ask_knowledge:" + q.ToLowerInvariant() + ":" + (rating ?? "");
if (!hopDone.Add(sig))
{
return null;
}
if (string.IsNullOrWhiteSpace(q))
{
return
"ask:knowledge needs knowledge_query (tags / scene / style). "
+ "Retry with \"ask\":[\"knowledge\"], \"knowledge_query\":\"redhead stockings\".";
}
int lim = Config.LoadAssistant(pid)["knowledge_hop_limit"]?.Value<int?>()
?? Config.LoadAssistant(pid)["examples_hop_limit"]?.Value<int?>() ?? 6;
JArray rows = SearchKnowledge(pid, q, lim, rating);
knowledgeHops?.Add(new JObject { ["tool"] = "ask_knowledge", ["query"] = q, ["count"] = rows.Count });
MergeKnowledgeResults(knowledgeResults, rows);
return
"ask:knowledge — FTS over attached books (read-only references). "
+ "Remix ideas; do not paste long verbatim.\n```json\n"
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```";
}
if (tool == "ask_examples") if (tool == "ask_examples")
{ {
string q = patch?["example_query"]?.ToString()?.Trim() string q = patch?["example_query"]?.ToString()?.Trim()
@@ -471,6 +511,8 @@ public partial class SwarmAssistentExtension
} }
int lim = Config.LoadAssistant(pid)["examples_hop_limit"]?.Value<int?>() ?? 5; int lim = Config.LoadAssistant(pid)["examples_hop_limit"]?.Value<int?>() ?? 5;
JArray examples = Memory?.LookupExamples(q, lim, rating) ?? []; JArray examples = Memory?.LookupExamples(q, lim, rating) ?? [];
knowledgeHops?.Add(new JObject { ["tool"] = "ask_examples", ["query"] = q, ["count"] = examples.Count });
MergeKnowledgeResults(knowledgeResults, examples, legacyExamples: true);
return return
"ask:examples — Civitai Krea2 prompt references (FTS, no embeddings). " "ask:examples — Civitai Krea2 prompt references (FTS, no embeddings). "
+ "These are EXAMPLES to remix, not copy 1:1. Prefer craft over pasting.\n```json\n" + "These are EXAMPLES to remix, not copy 1:1. Prefer craft over pasting.\n```json\n"
+134 -40
View File
@@ -239,6 +239,134 @@ public sealed class AssistentConfig
return map; return map;
} }
static List<string> ParseYamlBracketList(string value)
{
value = (value ?? "").Trim();
if (value.StartsWith('[') && value.EndsWith(']'))
{
value = value[1..^1];
}
return value.Split(',')
.Select(s => s.Trim().Trim('"', '\''))
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
}
public string TryFindPackManifestPath(string personaId)
{
string shelf = PackShelfRoot(personaId);
if (string.IsNullOrWhiteSpace(shelf))
{
return null;
}
string dir = shelf;
for (int i = 0; i < 5 && !string.IsNullOrWhiteSpace(dir); i++)
{
string yaml = Path.Combine(dir, "assistent-pack.yaml");
if (File.Exists(yaml))
{
return yaml;
}
dir = Directory.GetParent(dir)?.FullName;
}
return null;
}
/// <summary>Pack manifest knowledge.attach list for a pack persona.</summary>
public List<string> TryParsePackKnowledgeAttach(string personaId)
{
string path = TryFindPackManifestPath(personaId);
if (string.IsNullOrWhiteSpace(path))
{
return [];
}
List<string> items = [];
try
{
foreach (string rawLine in File.ReadAllLines(path, Encoding.UTF8))
{
string line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith("knowledge.attach:", StringComparison.OrdinalIgnoreCase))
{
string val = line["knowledge.attach:".Length..].Trim();
items.AddRange(ParseYamlBracketList(val));
break;
}
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentConfig pack knowledge.attach {path}: {ex.Message}");
}
return items
.Select(SafeId)
.Where(id => id is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public JObject LoadKnowledgeAttachOverlay(string personaId)
{
string id = SafeId(personaId);
if (id is null)
{
return null;
}
string path = Path.Combine(_overlayRoot, "personas", id, "knowledge.json");
return File.Exists(path) ? TryReadJson(path) : null;
}
public void SaveKnowledgeAttachOverlay(string personaId, JArray attach)
{
lock (_lock)
{
string id = SafeId(personaId) ?? throw new InvalidOperationException("invalid persona");
string dir = Path.Combine(_overlayRoot, "personas", id);
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, "knowledge.json");
JObject doc = new()
{
["attach"] = attach ?? new JArray(),
};
File.WriteAllText(path, doc.ToString(Newtonsoft.Json.Formatting.Indented) + "\n", Encoding.UTF8);
}
}
static readonly string[] DefaultBundledKnowledgeAttach = ["civitai-krea2", "ru-fictext-rplus"];
static readonly HashSet<string> BundledKnowledgePersonas = new(StringComparer.OrdinalIgnoreCase)
{
"neutral", "aggressive", "dreamer",
};
/// <summary>Effective book attach list for a persona (overlay → pack → bundled defaults).</summary>
public List<string> ResolveKnowledgeAttach(string personaId)
{
string pid = SafeId(personaId) ?? DefaultPersonaId();
JObject overlay = LoadKnowledgeAttachOverlay(pid);
if (overlay?["attach"] is JArray custom && custom.Count > 0)
{
return custom.Select(t => SafeId(t?.ToString()))
.Where(id => id is not null)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
List<string> fromPack = TryParsePackKnowledgeAttach(pid);
if (fromPack.Count > 0)
{
return fromPack;
}
if (IsBundledPersona(pid) && BundledKnowledgePersonas.Contains(pid))
{
return DefaultBundledKnowledgeAttach.ToList();
}
return [];
}
/// <summary>Discover persona packs under Assistent/extensions/*/.</summary> /// <summary>Discover persona packs under Assistent/extensions/*/.</summary>
public List<PackPersona> DiscoverPackPersonas() public List<PackPersona> DiscoverPackPersonas()
{ {
@@ -477,7 +605,7 @@ public sealed class AssistentConfig
static readonly HashSet<string> ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase) static readonly HashSet<string> ReservedConfigFiles = new(StringComparer.OrdinalIgnoreCase)
{ {
"exact.json", "controls.json", "skills.json", "ui.json", "assistant.json", "training-qlora.json", "exact.json", "controls.json", "skills.json", "ui.json", "assistant.json", "knowledge.json",
}; };
static readonly HashSet<string> ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase) static readonly HashSet<string> ReservedConfigDirs = new(StringComparer.OrdinalIgnoreCase)
@@ -780,7 +908,7 @@ public sealed class AssistentConfig
{ {
"persona.json", "bio.json", "voice.json", "humor.json", "craft.json", "persona.json", "bio.json", "voice.json", "humor.json", "craft.json",
"appearance.json", "outfits.json", "roleplay.json", "likes.json", "dislikes.json", "appearance.json", "outfits.json", "roleplay.json", "likes.json", "dislikes.json",
"rules.json", "controls.json", "exact.json", "extra.md", "rules.json", "controls.json", "exact.json", "knowledge.json", "extra.md",
}; };
public static bool IsWritableShelfName(string fileName) public static bool IsWritableShelfName(string fileName)
@@ -934,8 +1062,6 @@ public sealed class AssistentConfig
public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId)); public JObject LoadUi(string personaId) => MergeJsonLayers("ui.json", LayerRoots(personaId));
public JObject LoadTrainingQlora(string personaId) => MergeJsonLayers("training-qlora.json", LayerRoots(personaId));
/// <summary>Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.</summary> /// <summary>Exact (KV) memory: bundled base → persona overlays → disk overlays. Persona keys overwrite base.</summary>
public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId)); public JObject LoadExact(string personaId) => MergeJsonLayers("exact.json", LayerRoots(personaId));
@@ -1549,40 +1675,6 @@ public sealed class AssistentConfig
} }
} }
public JObject LoadTrainingRunner()
=> TryReadJson(Path.Combine(_overlayRoot, "training-runner.json")) ?? new JObject();
public void SaveTrainingRunner(JObject settings)
{
lock (_lock)
{
Directory.CreateDirectory(_overlayRoot);
string path = Path.Combine(_overlayRoot, "training-runner.json");
JObject merged = DeepMerge(LoadTrainingRunner(), settings ?? new JObject());
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
}
}
public JObject LoadTrainingAgent()
=> TryReadJson(Path.Combine(_overlayRoot, "training-agent.json"))
?? new JObject
{
["enabled"] = true,
["auto_link_on_approve"] = true,
["heard_quota"] = 3,
};
public void SaveTrainingAgent(JObject settings)
{
lock (_lock)
{
Directory.CreateDirectory(_overlayRoot);
string path = Path.Combine(_overlayRoot, "training-agent.json");
JObject merged = DeepMerge(LoadTrainingAgent(), settings ?? new JObject());
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
}
}
public JObject LoadOllamaRoles() public JObject LoadOllamaRoles()
{ {
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json")) return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
@@ -1643,7 +1735,6 @@ public sealed class AssistentConfig
JObject ui = LoadUi(id); JObject ui = LoadUi(id);
JObject model = LoadModelProfile(id); JObject model = LoadModelProfile(id);
JObject exact = LoadExact(id); JObject exact = LoadExact(id);
JObject training = LoadTrainingQlora(id);
var packs = ListPacks(id); var packs = ListPacks(id);
var skills = ListSkills(id); var skills = ListSkills(id);
var personas = ListPersonaCatalog(); var personas = ListPersonaCatalog();
@@ -1659,7 +1750,6 @@ public sealed class AssistentConfig
["ui"] = ui, ["ui"] = ui,
["model"] = model, ["model"] = model,
["exact"] = exact, ["exact"] = exact,
["training"] = training,
["controls"] = controlsSchema, ["controls"] = controlsSchema,
["control_values"] = controlValues, ["control_values"] = controlValues,
["persona_source"] = PersonaSource(id), ["persona_source"] = PersonaSource(id),
@@ -1687,6 +1777,10 @@ public sealed class AssistentConfig
["identity_summary"] = RenderIdentityBlock(id, includeAllShelves: true), ["identity_summary"] = RenderIdentityBlock(id, includeAllShelves: true),
["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)), ["enabled_skills"] = new JArray(ResolveEnabledSkills(id, null)),
["patch_keys"] = new JArray(LoadPatchKeys()), ["patch_keys"] = new JArray(LoadPatchKeys()),
["knowledge"] = new JObject
{
["attach"] = new JArray(ResolveKnowledgeAttach(id)),
},
}; };
} }
} }
-684
View File
@@ -1,684 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Hugging Face datasets: search, compatibility gate, preview, import.</summary>
public partial class SwarmAssistentExtension
{
static readonly Regex HfRepoIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}(/[A-Za-z0-9][A-Za-z0-9._-]{0,95})?$", RegexOptions.Compiled);
const string HfDatasetsServer = "https://datasets-server.huggingface.co";
const string HfHubApi = "https://huggingface.co/api/datasets";
static string GetHfToken(Session session)
=> session?.User?.GetGenericData("huggingface_api", "key")?.Trim();
static HttpRequestMessage HfRequest(string url, Session session)
{
HttpRequestMessage req = new(HttpMethod.Get, url);
string token = GetHfToken(session);
if (!string.IsNullOrWhiteSpace(token))
{
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
return req;
}
/// <summary>Normalize owner/name or HF datasets URL. Returns null when invalid.</summary>
public static string NormalizeHfDatasetId(string raw)
{
string s = (raw ?? "").Trim();
if (string.IsNullOrWhiteSpace(s))
{
return null;
}
if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (!Uri.TryCreate(s, UriKind.Absolute, out Uri uri))
{
return null;
}
if (!string.Equals(uri.Host, "huggingface.co", StringComparison.OrdinalIgnoreCase))
{
return null;
}
string[] parts = uri.AbsolutePath.Trim('/').Split('/');
if (parts.Length < 2 || !string.Equals(parts[0], "datasets", StringComparison.OrdinalIgnoreCase))
{
return null;
}
s = $"{parts[1]}/{parts[2]}";
}
s = s.Trim().TrimEnd('/');
return HfRepoIdRe.IsMatch(s) ? s : null;
}
public async Task<JObject> AssistentSearchHfDatasets(Session session, string q = null, int limit = 20, bool show_all = false)
{
int take = Math.Clamp(limit, 1, 50);
string search = (q ?? "").Trim();
StringBuilder url = new($"{HfHubApi}?limit={take}&full=true");
url.Append("&filter=task_categories:text-generation");
url.Append("&filter=modality:text");
if (!string.IsNullOrWhiteSpace(search))
{
url.Append("&search=").Append(Uri.EscapeDataString(search));
}
try
{
using HttpRequestMessage req = HfRequest(url.ToString(), session);
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
return new JObject { ["error"] = $"HF search HTTP {(int)resp.StatusCode}: {Clip(body, 300)}" };
}
JArray rawList = JArray.Parse(body);
JArray results = [];
foreach (JToken item in rawList)
{
if (item is not JObject o)
{
continue;
}
string id = o["id"]?.ToString();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
JObject check = await CheckHfDatasetInternal(session, id, useCache: true);
string gate = check["gate"]?.ToString() ?? "rejected";
if (!show_all && gate == "rejected")
{
continue;
}
results.Add(new JObject
{
["id"] = id,
["title"] = o["id"],
["downloads"] = o["downloads"],
["gate"] = gate,
["reason"] = check["reason"],
["schema"] = check["schema"],
});
}
return new JObject { ["success"] = true, ["results"] = results, ["has_hf_token"] = !string.IsNullOrWhiteSpace(GetHfToken(session)) };
}
catch (Exception ex)
{
return new JObject { ["error"] = $"HF search: {ex.Message}" };
}
}
public async Task<JObject> AssistentCheckHfDataset(Session session, string dataset)
{
string id = NormalizeHfDatasetId(dataset);
if (id is null)
{
return new JObject { ["success"] = false, ["error"] = "Нужен owner/name или ссылка huggingface.co/datasets/…" };
}
JObject check = await CheckHfDatasetInternal(session, id, useCache: false);
check["success"] = check["gate"]?.ToString() != "rejected";
check["id"] = id;
return check;
}
async Task<JObject> CheckHfDatasetInternal(Session session, string datasetId, bool useCache)
{
string cacheKey = $"hf:{datasetId}";
if (useCache)
{
JObject cached = Memory.GetKvObject(AssistentMemory.KvHfDatasetCache)?[cacheKey] as JObject;
if (cached is not null && cached["checked_at"]?.Value<long?>() > DateTimeOffset.UtcNow.AddHours(-6).ToUnixTimeMilliseconds())
{
return cached;
}
}
JObject result = new() { ["id"] = datasetId, ["gate"] = "rejected", ["reason"] = "unknown" };
try
{
using HttpRequestMessage validReq = HfRequest($"{HfDatasetsServer}/is-valid?dataset={Uri.EscapeDataString(datasetId)}", session);
using HttpResponseMessage validResp = await HttpClient.SendAsync(validReq);
string validBody = await validResp.Content.ReadAsStringAsync();
if (!validResp.IsSuccessStatusCode)
{
result["reason"] = $"is-valid HTTP {(int)validResp.StatusCode}";
return CacheHfCheck(cacheKey, result);
}
JObject valid = JObject.Parse(validBody);
bool viewer = valid["viewer"]?.Value<bool?>() == true;
bool preview = valid["preview"]?.Value<bool?>() == true;
if (!viewer && !preview)
{
result["reason"] = "Набор не читается через datasets (viewer/preview = false)";
return CacheHfCheck(cacheKey, result);
}
using HttpRequestMessage splitReq = HfRequest($"{HfDatasetsServer}/splits?dataset={Uri.EscapeDataString(datasetId)}", session);
using HttpResponseMessage splitResp = await HttpClient.SendAsync(splitReq);
string splitBody = await splitResp.Content.ReadAsStringAsync();
if (!splitResp.IsSuccessStatusCode)
{
result["reason"] = $"splits HTTP {(int)splitResp.StatusCode}";
return CacheHfCheck(cacheKey, result);
}
JArray splits = JObject.Parse(splitBody)["splits"] as JArray ?? [];
if (splits.Count == 0)
{
result["reason"] = "Нет splits";
return CacheHfCheck(cacheKey, result);
}
JObject first = splits[0] as JObject;
string config = first?["config"]?.ToString() ?? "default";
string split = first?["split"]?.ToString() ?? "train";
using HttpRequestMessage rowsReq = HfRequest($"{HfDatasetsServer}/first-rows?dataset={Uri.EscapeDataString(datasetId)}&config={Uri.EscapeDataString(config)}&split={Uri.EscapeDataString(split)}", session);
using HttpResponseMessage rowsResp = await HttpClient.SendAsync(rowsReq);
string rowsBody = await rowsResp.Content.ReadAsStringAsync();
if (!rowsResp.IsSuccessStatusCode)
{
result["reason"] = $"first-rows HTTP {(int)rowsResp.StatusCode}: {Clip(rowsBody, 200)}";
return CacheHfCheck(cacheKey, result);
}
JObject rowsData = JObject.Parse(rowsBody);
JObject features = FeaturesToObject(rowsData["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["reason"] = reason;
result["schema"] = schema;
result["config"] = config;
result["split"] = split;
result["features"] = features;
result["sample_rows"] = rowsData["rows"];
result["runner_only"] = await HasHugeSizeTagAsync(session, datasetId);
return CacheHfCheck(cacheKey, result);
}
catch (Exception ex)
{
result["reason"] = ex.Message;
return CacheHfCheck(cacheKey, result);
}
}
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)
{
result["checked_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
try
{
JObject bag = Memory.GetKvObject(AssistentMemory.KvHfDatasetCache) ?? new JObject();
bag[cacheKey] = result;
Memory.SetKvObject(AssistentMemory.KvHfDatasetCache, bag);
}
catch (Exception ex)
{
Logs.Debug($"CacheHfCheck: {ex.Message}");
}
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)
{
if (features is null || !features.Properties().Any())
{
return ("rejected", "Нет колонок (features пуст)", null);
}
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
foreach (JProperty p in features.Properties())
{
names.Add(p.Name);
JToken dtype = p.Value?["dtype"] ?? p.Value?["type"];
string dt = dtype?.ToString() ?? "";
if (dt.Contains("image", StringComparison.OrdinalIgnoreCase)
|| dt.Contains("audio", StringComparison.OrdinalIgnoreCase)
|| dt.Contains("video", StringComparison.OrdinalIgnoreCase))
{
return ("rejected", $"Мультимодальная колонка {p.Name} ({dt})", null);
}
}
if (names.Contains("chosen") && names.Contains("rejected"))
{
return ("rejected", "DPO-набор (chosen/rejected) — не для SFT", null);
}
if (names.Count == 1 && names.Contains("text"))
{
return ("rejected", "Предобучение (одна колонка text), не диалоги", null);
}
if (names.Contains("messages"))
{
return ("ok", "OpenAI messages", new JObject { ["kind"] = "messages" });
}
if (names.Contains("conversations"))
{
return ("ok", "ShareGPT conversations", new JObject { ["kind"] = "conversations" });
}
if (names.Contains("instruction") && names.Contains("output"))
{
return ("ok", "Alpaca instruction/output", new JObject { ["kind"] = "alpaca" });
}
if (names.Contains("prompt") && (names.Contains("response") || names.Contains("completion") || names.Contains("answer")))
{
string respCol = names.Contains("response") ? "response" : names.Contains("completion") ? "completion" : "answer";
return ("ok", "prompt/response", new JObject { ["kind"] = "prompt_response", ["response_col"] = respCol });
}
if (names.Contains("question") && names.Contains("answer"))
{
return ("ok", "question/answer", new JObject { ["kind"] = "qa" });
}
List<string> stringCols = [];
foreach (JProperty p in features.Properties())
{
JToken dtype = p.Value?["dtype"] ?? p.Value?["type"];
string dt = dtype?.ToString() ?? "";
if (dt.Contains("string", StringComparison.OrdinalIgnoreCase) || dt == "value")
{
stringCols.Add(p.Name);
}
}
if (stringCols.Count >= 2)
{
return ("mapping", "Нужен ручной маппинг колонок", new JObject
{
["kind"] = "custom",
["columns"] = new JArray(stringCols),
});
}
return ("rejected", "Схема не подходит для SFT", null);
}
public async Task<JObject> AssistentPreviewHfDataset(Session session, string dataset, string config = null, string split = null)
{
string id = NormalizeHfDatasetId(dataset);
if (id is null)
{
return new JObject { ["error"] = "invalid dataset id" };
}
JObject check = await CheckHfDatasetInternal(session, id, useCache: true);
if (check["gate"]?.ToString() == "rejected")
{
return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected", ["check"] = check };
}
return new JObject { ["success"] = true, ["check"] = check };
}
public async Task<JObject> AssistentImportHfDataset(Session session, JObject raw)
{
try
{
string dataset = raw?["dataset"]?.ToString();
int limit = raw?["limit"]?.Value<int?>() ?? 200;
JObject mapping = raw?["mapping"] as JObject;
string id = NormalizeHfDatasetId(dataset);
if (id is null)
{
return new JObject { ["error"] = "invalid dataset id" };
}
JObject check = await CheckHfDatasetInternal(session, id, useCache: true);
string gate = check["gate"]?.ToString();
if (gate == "rejected")
{
return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected" };
}
if (MappingRequired(check, mapping))
{
return new JObject { ["error"] = "Нужен маппинг колонок", ["check"] = check };
}
mapping = ResolveHfMapping(check, mapping);
int take = Math.Clamp(limit, 1, 5000);
JArray rows = [];
string config = check["config"]?.ToString() ?? "default";
string split = check["split"]?.ToString() ?? "train";
int offset = 0;
while (rows.Count < take)
{
int chunk = Math.Min(100, take - rows.Count);
using HttpRequestMessage rowsReq = HfRequest($"{HfDatasetsServer}/rows?dataset={Uri.EscapeDataString(id)}&config={Uri.EscapeDataString(config)}&split={Uri.EscapeDataString(split)}&offset={offset}&length={chunk}", session);
using HttpResponseMessage rowsResp = await HttpClient.SendAsync(rowsReq);
string rowsBody = await rowsResp.Content.ReadAsStringAsync();
if (!rowsResp.IsSuccessStatusCode)
{
if (rows.Count == 0)
{
return new JObject
{
["error"] = $"HF rows HTTP {(int)rowsResp.StatusCode}: {Clip(rowsBody, 240)}. "
+ "Для gated/NSFW добавь huggingface_api в User Settings.",
};
}
break;
}
JObject parsed = JObject.Parse(rowsBody);
JArray batch = parsed["rows"] as JArray ?? [];
if (batch.Count == 0)
{
break;
}
foreach (JToken t in batch)
{
rows.Add(t);
}
offset += batch.Count;
if (batch.Count < chunk)
{
break;
}
}
if (rows.Count == 0)
{
rows = check["sample_rows"] as JArray ?? [];
}
List<JObject> toSave = [];
foreach (JToken rowTok in rows.Take(take))
{
if (rowTok is not JObject row)
{
continue;
}
JObject rowData = row["row"] as JObject ?? row;
JArray messages = ConvertHfRowToMessages(rowData, check["schema"] as JObject, mapping);
if (messages is null || messages.Count == 0)
{
continue;
}
toSave.Add(new JObject
{
["source"] = "hf",
["hf_repo"] = id,
["messages"] = messages,
["status"] = "draft",
});
}
int imported = toSave.Count > 0 ? Memory.ImportTrainSamplesBatch(toSave) : 0;
if (imported == 0 && check["runner_only"]?.Value<bool?>() == true)
{
return new JObject { ["success"] = true, ["imported"] = 0, ["runner_only"] = true, ["id"] = id, ["note"] = "Большой набор — используй HF id в QLoRA-раннере" };
}
if (imported == 0)
{
return new JObject
{
["error"] = rows.Count == 0
? "HF не отдал строки — проверь token (gated/NSFW) и маппинг колонок"
: "0 строк после маппинга — проверь колонки user/assistant",
["rows_fetched"] = rows.Count,
["id"] = id,
};
}
return new JObject
{
["success"] = true,
["imported"] = imported,
["id"] = id,
["rows_fetched"] = rows.Count,
["status"] = "draft",
};
}
catch (Exception ex)
{
Logs.Error($"AssistentImportHfDataset: {ex}");
return new JObject { ["error"] = ex.Message };
}
}
static string HfCellString(JToken tok)
{
if (tok is null || tok.Type == JTokenType.Null)
{
return "";
}
if (tok is JArray arr)
{
List<string> parts = [];
foreach (JToken t in arr)
{
string s = t?.Type == JTokenType.String ? t.ToString() : t?.ToString(Newtonsoft.Json.Formatting.None);
if (!string.IsNullOrWhiteSpace(s))
{
parts.Add(s.Trim());
}
}
return string.Join(", ", parts);
}
return tok.ToString().Trim();
}
static JArray ConvertHfRowToMessages(JObject row, JObject schema, JObject mapping)
{
string kind = schema?["kind"]?.ToString() ?? mapping?["kind"]?.ToString();
if (kind == "messages" && row["messages"] is JArray msgs)
{
return NormalizeMessagesArray(msgs);
}
if (kind == "conversations" && row["conversations"] is JArray conv)
{
JArray outArr = [];
foreach (JToken c in conv)
{
if (c is not JObject co)
{
continue;
}
string from = co["from"]?.ToString() ?? "";
string val = co["value"]?.ToString() ?? "";
string role = from is "human" or "user" ? "user" : from is "gpt" or "assistant" or "chatgpt" ? "assistant" : "user";
outArr.Add(new JObject { ["role"] = role, ["content"] = val });
}
return outArr.Count > 0 ? outArr : null;
}
if (kind == "alpaca")
{
string instr = row["instruction"]?.ToString() ?? "";
string inp = row["input"]?.ToString() ?? "";
string output = row["output"]?.ToString() ?? "";
string user = string.IsNullOrWhiteSpace(inp) ? instr : $"{instr}\n{inp}";
return new JArray
{
new JObject { ["role"] = "user", ["content"] = user },
new JObject { ["role"] = "assistant", ["content"] = output },
};
}
if (kind == "prompt_response")
{
string respCol = schema?["response_col"]?.ToString() ?? "response";
return new JArray
{
new JObject { ["role"] = "user", ["content"] = row["prompt"]?.ToString() ?? "" },
new JObject { ["role"] = "assistant", ["content"] = row[respCol]?.ToString() ?? "" },
};
}
if (kind == "qa")
{
return new JArray
{
new JObject { ["role"] = "user", ["content"] = row["question"]?.ToString() ?? "" },
new JObject { ["role"] = "assistant", ["content"] = row["answer"]?.ToString() ?? "" },
};
}
if (kind == "fiction_tags_text")
{
string text = HfCellString(row["text"]);
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
List<string> userParts = [];
string title = HfCellString(row["title"]);
string tags = HfCellString(row["tags"]);
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)
{
string userCol = mapping["user_col"]?.ToString();
string asstCol = mapping["assistant_col"]?.ToString();
if (!string.IsNullOrWhiteSpace(userCol) && !string.IsNullOrWhiteSpace(asstCol))
{
return new JArray
{
new JObject { ["role"] = "user", ["content"] = row[userCol]?.ToString() ?? "" },
new JObject { ["role"] = "assistant", ["content"] = row[asstCol]?.ToString() ?? "" },
};
}
}
return null;
}
static JArray NormalizeMessagesArray(JArray msgs)
{
JArray outArr = [];
foreach (JToken m in msgs)
{
if (m is not JObject mo)
{
continue;
}
string role = mo["role"]?.ToString() ?? "user";
string content = mo["content"]?.ToString() ?? mo["text"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(content))
{
continue;
}
outArr.Add(new JObject { ["role"] = role, ["content"] = content });
}
return outArr.Count > 0 ? outArr : null;
}
}
+214
View File
@@ -0,0 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Unified knowledge hub: books FTS + legacy examples shim + catalog for personas.</summary>
public partial class SwarmAssistentExtension
{
public List<string> ResolveAttachedBooks(string personaId)
=> Config.ResolveKnowledgeAttach(personaId);
public JObject BuildKnowledgeCatalog(string personaId)
{
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
List<string> attached = ResolveAttachedBooks(pid);
JArray books = Memory?.ListBooksOnDisk() ?? [];
HashSet<string> attachSet = attached.ToHashSet(StringComparer.OrdinalIgnoreCase);
JArray catalog = [];
foreach (JToken t in books)
{
if (t is not JObject b)
{
continue;
}
string id = b["id"]?.ToString() ?? "";
catalog.Add(new JObject
{
["id"] = id,
["title"] = b["title"] ?? id,
["description"] = b["description"] ?? "",
["content_kind"] = b["content_kind"] ?? "",
["language"] = b["language"] ?? "",
["attached"] = attachSet.Contains(id),
["indexed"] = b["indexed"]?.Value<bool?>() ?? false,
});
}
return new JObject
{
["persona"] = pid,
["attach"] = new JArray(attached),
["books"] = catalog,
["books_indexed"] = Memory?.BookRowCount() ?? 0,
["examples_indexed"] = Memory?.ExampleCount() ?? 0,
["tags_indexed"] = Memory?.TagCount() ?? 0,
};
}
public string BuildKnowledgeSystemLayer(string personaId)
{
JObject cat = BuildKnowledgeCatalog(personaId);
JArray attach = cat["attach"] as JArray ?? [];
if (attach.Count == 0)
{
return "";
}
StringBuilder sb = new();
sb.AppendLine("## Knowledge books (FTS reference — read-only)");
sb.AppendLine("Attached corpora for this persona. Search via ask:[\"knowledge\"] + knowledge_query (or legacy ask:[\"examples\"] + example_query).");
foreach (JToken t in attach)
{
string id = t?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
JObject meta = (cat["books"] as JArray)?.OfType<JObject>()
.FirstOrDefault(b => string.Equals(b["id"]?.ToString(), id, StringComparison.OrdinalIgnoreCase));
string title = meta?["title"]?.ToString() ?? id;
string kind = meta?["content_kind"]?.ToString() ?? "";
string desc = meta?["description"]?.ToString() ?? "";
sb.AppendLine($"- **{id}** ({title}){ (string.IsNullOrWhiteSpace(kind) ? "" : $" · {kind}")}");
if (!string.IsNullOrWhiteSpace(desc))
{
sb.AppendLine($" {desc}");
}
}
return sb.ToString().TrimEnd();
}
public JArray SearchKnowledge(string personaId, string query, int limit = 8, string rating = null)
{
query = (query ?? "").Trim();
if (query.Length < 1 || Memory is null)
{
return [];
}
List<string> attached = ResolveAttachedBooks(personaId);
JArray hits = [];
HashSet<string> seen = [];
if (attached.Count > 0)
{
foreach (JToken t in Memory.LookupBooks(query, limit, attached, rating))
{
string key = t?["id"]?.ToString() ?? t?.ToString();
if (!string.IsNullOrWhiteSpace(key) && seen.Add(key))
{
hits.Add(t);
}
}
}
if (hits.Count < limit && attached.Any(b => string.Equals(b, "civitai-krea2", StringComparison.OrdinalIgnoreCase)))
{
int remain = limit - hits.Count;
foreach (JToken t in Memory.LookupExamples(query, remain, rating))
{
string key = "legacy:" + (t?["id"]?.ToString() ?? "");
if (!seen.Add(key))
{
continue;
}
JObject row = t as JObject ?? new JObject();
hits.Add(new JObject
{
["id"] = row["id"],
["book"] = "civitai-krea2",
["source"] = "examples-legacy",
["title"] = "",
["tags"] = row["tags"],
["text"] = row["prompt"],
["body"] = row["prompt"],
["rating"] = row["rating"],
["params"] = row["params"],
["loras"] = row["loras"],
["note"] = row["note"],
});
}
}
return hits;
}
public static JArray CivitaiResultsShim(JArray knowledgeResults)
{
JArray outRows = [];
foreach (JToken t in knowledgeResults ?? [])
{
if (t is not JObject row)
{
continue;
}
string book = row["book"]?.ToString() ?? "";
string source = row["source"]?.ToString() ?? "";
if (!string.Equals(book, "civitai-krea2", StringComparison.OrdinalIgnoreCase)
&& source is not "examples-legacy")
{
continue;
}
if (row["prompt"] is not null)
{
outRows.Add(row);
continue;
}
outRows.Add(new JObject
{
["id"] = row["id"],
["rating"] = row["rating"],
["tags"] = row["tags"],
["prompt"] = row["text"] ?? row["body"],
["negative"] = row["meta"]?["negative"] ?? "",
["params"] = row["params"] ?? row["meta"]?["params"],
["loras"] = row["loras"] ?? row["meta"]?["loras"],
["note"] = row["note"] ?? "EXAMPLE from Civitai — remix, do not copy 1:1",
});
}
return outRows;
}
JObject BuildKnowledgeResponse(string personaId, JArray hops, JArray results)
=> new()
{
["catalog"] = BuildKnowledgeCatalog(personaId),
["hops"] = hops ?? new JArray(),
["results"] = results ?? new JArray(),
};
static void MergeKnowledgeResults(JArray target, JArray rows, bool legacyExamples = false)
{
if (target is null || rows is null)
{
return;
}
HashSet<string> seen = target
.Select(t => t?["id"]?.ToString() ?? t?.ToString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (JToken t in rows)
{
JObject row = t as JObject ?? new JObject { ["text"] = t?.ToString() };
if (legacyExamples)
{
row = new JObject
{
["id"] = row["id"],
["book"] = "civitai-krea2",
["source"] = "examples-legacy",
["tags"] = row["tags"],
["text"] = row["prompt"],
["body"] = row["prompt"],
["rating"] = row["rating"],
["note"] = row["note"],
};
}
string key = row["id"]?.ToString() ?? row["text"]?.ToString();
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
{
continue;
}
target.Add(row);
}
}
}
+129
View File
@@ -0,0 +1,129 @@
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
namespace Mrleo1nid.SwarmAssistent;
public partial class SwarmAssistentExtension
{
public async Task<JObject> AssistentListKnowledgeCatalog(Session session, string persona = null)
{
await Task.CompletedTask;
if (Memory is null)
{
return new JObject { ["error"] = "memory not ready" };
}
try
{
Memory.EnsureBooksIndex();
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
return new JObject
{
["success"] = true,
["knowledge"] = BuildKnowledgeCatalog(pid),
["editable"] = Config.IsOverlayPersona(pid) && !Config.IsProtectedPersona(pid),
};
}
catch (System.Exception ex)
{
return new JObject { ["error"] = $"knowledge catalog: {ex.Message}" };
}
}
public async Task<JObject> AssistentSearchKnowledge(Session session, string query, string persona = null, int limit = 8, string rating = null, string book = null)
{
await Task.CompletedTask;
if (Memory is null)
{
return new JObject { ["error"] = "memory not ready" };
}
query = (query ?? "").Trim();
if (query.Length < 1)
{
return new JObject { ["error"] = "query required" };
}
try
{
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
JArray results;
if (!string.IsNullOrWhiteSpace(book))
{
results = Memory.LookupBooks(query, limit, [book.Trim()], rating);
}
else
{
results = SearchKnowledge(pid, query, limit, rating);
}
return new JObject
{
["success"] = true,
["query"] = query,
["persona"] = pid,
["results"] = results,
["civitai_results"] = CivitaiResultsShim(results),
};
}
catch (System.Exception ex)
{
return new JObject { ["error"] = $"knowledge search: {ex.Message}" };
}
}
public async Task<JObject> AssistentGetKnowledgeAttach(Session session, string persona = null)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona) ?? Config.DefaultPersonaId();
return new JObject
{
["success"] = true,
["persona"] = pid,
["attach"] = new JArray(ResolveAttachedBooks(pid)),
["editable"] = Config.IsOverlayPersona(pid) && !Config.IsProtectedPersona(pid),
["source"] = Config.PersonaSource(pid),
};
}
public async Task<JObject> AssistentSaveKnowledgeAttach(Session session, string persona, JArray attach)
{
await Task.CompletedTask;
string pid = AssistentConfig.SafeId(persona);
if (pid is null)
{
return new JObject { ["error"] = "persona required" };
}
if (Config.IsProtectedPersona(pid))
{
return new JObject { ["error"] = "bundled/pack personas cannot edit attach here — clone to overlay first" };
}
if (!Config.IsOverlayPersona(pid))
{
return new JObject { ["error"] = "overlay persona required" };
}
try
{
JArray cleaned = [];
if (attach is not null)
{
foreach (JToken t in attach)
{
string id = AssistentConfig.SafeId(t?.ToString());
if (id is not null)
{
cleaned.Add(id);
}
}
}
Config.SaveKnowledgeAttachOverlay(pid, cleaned);
return new JObject
{
["success"] = true,
["persona"] = pid,
["attach"] = new JArray(ResolveAttachedBooks(pid)),
};
}
catch (System.Exception ex)
{
return new JObject { ["error"] = $"save knowledge attach: {ex.Message}" };
}
}
}
+458
View File
@@ -0,0 +1,458 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Reference books (search.jsonl) — FTS only, seeded by gpu-rent to Assistent/books/.</summary>
public sealed partial class AssistentMemory
{
const string BooksMetaPrefix = "books_fp:";
void TryIndexBooks()
{
try
{
EnsureBooksIndex();
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory books index: {ex.Message}");
}
}
public string BooksRoot()
=> Path.Combine(_dataRoot, "Assistent", "books");
void EnsureBooksSchema()
{
Exec(
"""
CREATE TABLE IF NOT EXISTS books_rows (
id TEXT PRIMARY KEY,
book_id TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL DEFAULT '',
body TEXT NOT NULL DEFAULT '',
rating TEXT NOT NULL DEFAULT '',
meta_json TEXT NOT NULL DEFAULT '{}',
search_blob TEXT NOT NULL DEFAULT ''
);
""");
Exec("CREATE INDEX IF NOT EXISTS idx_books_rows_book ON books_rows(book_id);");
Exec(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS books_fts USING fts5(
book_id,
title,
tags,
text,
body,
search_blob,
tokenize = 'unicode61 remove_diacritics 2'
);
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS books_fts_ai AFTER INSERT ON books_rows BEGIN
INSERT INTO books_fts(rowid, book_id, title, tags, text, body, search_blob)
VALUES (new.rowid, new.book_id, new.title, new.tags, new.text, new.body, new.search_blob);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS books_fts_ad AFTER DELETE ON books_rows BEGIN
INSERT INTO books_fts(books_fts, rowid) VALUES('delete', old.rowid);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS books_fts_au AFTER UPDATE ON books_rows BEGIN
INSERT INTO books_fts(books_fts, rowid) VALUES('delete', old.rowid);
INSERT INTO books_fts(rowid, book_id, title, tags, text, body, search_blob)
VALUES (new.rowid, new.book_id, new.title, new.tags, new.text, new.body, new.search_blob);
END;
""");
}
static string ReadBookContentSha(string bookDir)
{
foreach (string name in new[] { ".gpu-rent-meta.json", "meta.json" })
{
string path = Path.Combine(bookDir, name);
if (!File.Exists(path))
{
continue;
}
try
{
JObject meta = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
string sha = meta["content_sha"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(sha))
{
return sha;
}
}
catch
{
// ignore
}
}
string jsonl = Path.Combine(bookDir, "search.jsonl");
if (File.Exists(jsonl))
{
return FileFingerprint(jsonl);
}
return null;
}
/// <summary>Reindex changed books from disk. Returns total row count.</summary>
public int EnsureBooksIndex()
{
string root = BooksRoot();
if (!Directory.Exists(root))
{
return BookRowCount();
}
int total = 0;
foreach (string bookDir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
{
string bookId = Path.GetFileName(bookDir);
if (string.IsNullOrWhiteSpace(bookId))
{
continue;
}
string jsonl = Path.Combine(bookDir, "search.jsonl");
if (!File.Exists(jsonl))
{
continue;
}
string fp = ReadBookContentSha(bookDir) ?? FileFingerprint(jsonl);
lock (_lock)
{
EnsureOpen();
EnsureBooksSchema();
string metaKey = BooksMetaPrefix + bookId;
if (string.Equals(GetMeta(metaKey), fp, StringComparison.Ordinal))
{
continue;
}
}
int n = IndexBookJsonl(bookId, jsonl, fp);
total += n;
}
if (total > 0)
{
Logs.Info($"AssistentMemory: indexed {total} book rows under {root}");
}
return BookRowCount();
}
int IndexBookJsonl(string bookId, string jsonlPath, string fingerprint)
{
List<(string id, string title, string tags, string text, string body, string rating, string metaJson, string blob)> rows = [];
foreach (string line in File.ReadLines(jsonlPath, Encoding.UTF8))
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}
JObject o;
try
{
o = JObject.Parse(line);
}
catch
{
continue;
}
string id = o["id"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
id = $"{bookId}:{rows.Count + 1}";
}
string title = o["title"]?.ToString() ?? "";
string tagsJoined = "";
if (o["tags"] is JArray tagArr)
{
tagsJoined = string.Join(", ", tagArr.Select(t => t?.ToString()?.Trim()).Where(t => !string.IsNullOrWhiteSpace(t)));
}
string text = o["text"]?.ToString() ?? "";
string body = o["body"]?.ToString() ?? text;
if (string.IsNullOrWhiteSpace(text) && string.IsNullOrWhiteSpace(body))
{
continue;
}
string rating = o["rating"]?.ToString() ?? "";
string metaJson = (o["meta"] as JObject)?.ToString(Newtonsoft.Json.Formatting.None) ?? "{}";
string blob = $"{title}\n{tagsJoined}\n{text}\n{body}\n{rating}";
rows.Add((id, title, tagsJoined, text, body, rating, metaJson, blob));
}
lock (_lock)
{
EnsureOpen();
EnsureBooksSchema();
using SqliteTransaction tx = _conn.BeginTransaction();
using (SqliteCommand del = _conn.CreateCommand())
{
del.Transaction = tx;
del.CommandText = "DELETE FROM books_rows WHERE book_id = $b";
del.Parameters.AddWithValue("$b", bookId);
del.ExecuteNonQuery();
}
using (SqliteCommand ins = _conn.CreateCommand())
{
ins.Transaction = tx;
ins.CommandText =
"""
INSERT OR REPLACE INTO books_rows(
id, book_id, title, tags, text, body, rating, meta_json, search_blob)
VALUES($id,$b,$t,$tg,$tx,$bd,$r,$mj,$bl)
""";
var pid = ins.Parameters.Add("$id", SqliteType.Text);
var pb = ins.Parameters.Add("$b", SqliteType.Text);
var pt = ins.Parameters.Add("$t", SqliteType.Text);
var ptg = ins.Parameters.Add("$tg", SqliteType.Text);
var ptx = ins.Parameters.Add("$tx", SqliteType.Text);
var pbd = ins.Parameters.Add("$bd", SqliteType.Text);
var pr = ins.Parameters.Add("$r", SqliteType.Text);
var pmj = ins.Parameters.Add("$mj", SqliteType.Text);
var pbl = ins.Parameters.Add("$bl", SqliteType.Text);
foreach (var row in rows)
{
pid.Value = row.id;
pb.Value = bookId;
pt.Value = row.title;
ptg.Value = row.tags;
ptx.Value = row.text;
pbd.Value = row.body;
pr.Value = row.rating;
pmj.Value = row.metaJson;
pbl.Value = row.blob;
ins.ExecuteNonQuery();
}
}
tx.Commit();
SetMeta(BooksMetaPrefix + bookId, fingerprint);
Logs.Info($"AssistentMemory: indexed book {bookId} ({rows.Count} rows)");
return rows.Count;
}
}
public int BookRowCount()
{
lock (_lock)
{
EnsureOpen();
if (!TableExists("books_rows"))
{
return 0;
}
using SqliteCommand c = _conn.CreateCommand();
c.CommandText = "SELECT COUNT(*) FROM books_rows";
return Convert.ToInt32(c.ExecuteScalar());
}
}
public JArray ListBooksOnDisk()
{
JArray list = [];
string root = BooksRoot();
if (!Directory.Exists(root))
{
return list;
}
foreach (string bookDir in Directory.GetDirectories(root).OrderBy(d => d, StringComparer.OrdinalIgnoreCase))
{
string id = Path.GetFileName(bookDir);
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
JObject spec = new() { ["id"] = id };
string yaml = Path.Combine(bookDir, "book.yaml");
if (File.Exists(yaml))
{
foreach (string rawLine in File.ReadAllLines(yaml, Encoding.UTF8))
{
string line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
int colon = line.IndexOf(':');
if (colon <= 0)
{
continue;
}
string key = line[..colon].Trim();
string val = line[(colon + 1)..].Trim().Trim('"', '\'');
if (key is "title" or "description" or "content_kind" or "language")
{
spec[key] = val;
}
}
}
string jsonl = Path.Combine(bookDir, "search.jsonl");
spec["indexed"] = File.Exists(jsonl);
spec["content_sha"] = ReadBookContentSha(bookDir) ?? "";
list.Add(spec);
}
return list;
}
/// <summary>FTS lookup over attached books (filter by book_id list when provided).</summary>
public JArray LookupBooks(string query, int limit = 8, IEnumerable<string> bookIds = null, string rating = null)
{
query = (query ?? "").Trim();
if (query.Length < 1)
{
return [];
}
TryIndexBooks();
int cap = Math.Clamp(limit, 1, 30);
HashSet<string> bookFilter = null;
if (bookIds is not null)
{
bookFilter = bookIds
.Select(b => (b ?? "").Trim())
.Where(b => !string.IsNullOrWhiteSpace(b))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (bookFilter.Count == 0)
{
return [];
}
}
string ratingFilter = string.IsNullOrWhiteSpace(rating) ? null : rating.Trim().ToLowerInvariant();
List<JObject> hits = [];
HashSet<string> seen = [];
void Add(SqliteDataReader reader)
{
string id = reader.GetString(0);
if (!seen.Add(id))
{
return;
}
string bookId = reader.IsDBNull(1) ? "" : reader.GetString(1);
if (bookFilter is not null && !bookFilter.Contains(bookId))
{
seen.Remove(id);
return;
}
string title = reader.IsDBNull(2) ? "" : reader.GetString(2);
string tags = reader.IsDBNull(3) ? "" : reader.GetString(3);
string text = reader.IsDBNull(4) ? "" : reader.GetString(4);
string body = reader.IsDBNull(5) ? "" : reader.GetString(5);
string rowRating = reader.IsDBNull(6) ? "" : reader.GetString(6);
JToken metaTok = new JObject();
if (!reader.IsDBNull(7))
{
try { metaTok = JToken.Parse(reader.GetString(7)); } catch { metaTok = new JObject(); }
}
hits.Add(new JObject
{
["id"] = id,
["book"] = bookId,
["source"] = "book",
["title"] = title,
["tags"] = tags,
["text"] = text.Length > 600 ? text[..600] + "…" : text,
["body"] = body.Length > 1200 ? body[..1200] + "…" : body,
["rating"] = rowRating,
["meta"] = metaTok,
["note"] = "BOOK reference — remix style/ideas, do not paste long verbatim",
});
}
lock (_lock)
{
EnsureOpen();
if (!TableExists("books_rows"))
{
return [];
}
string match = BuildFtsMatch(query);
if (!string.IsNullOrWhiteSpace(match) && TableExists("books_fts"))
{
try
{
using SqliteCommand cmd = _conn.CreateCommand();
string sql =
"""
SELECT b.id, b.book_id, b.title, b.tags, b.text, b.body, b.rating, b.meta_json
FROM books_rows b
WHERE b.rowid IN (SELECT rowid FROM books_fts WHERE books_fts MATCH $q)
""";
if (ratingFilter is not null)
{
sql += " AND lower(b.rating) = $r";
}
sql += " LIMIT $lim";
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("$q", match);
if (ratingFilter is not null)
{
cmd.Parameters.AddWithValue("$r", ratingFilter);
}
cmd.Parameters.AddWithValue("$lim", cap);
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read() && hits.Count < cap)
{
Add(reader);
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory books FTS: {ex.Message}");
}
}
if (hits.Count < cap)
{
try
{
using SqliteCommand cmd = _conn.CreateCommand();
string sql =
"""
SELECT id, book_id, title, tags, text, body, rating, meta_json
FROM books_rows
WHERE (title LIKE $p ESCAPE '\' OR tags LIKE $p ESCAPE '\' OR text LIKE $p ESCAPE '\' OR body LIKE $p ESCAPE '\' OR search_blob LIKE $p ESCAPE '\')
""";
if (ratingFilter is not null)
{
sql += " AND lower(rating) = $r";
}
sql += " LIMIT $lim";
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("$p", "%" + EscapeLike(query) + "%");
if (ratingFilter is not null)
{
cmd.Parameters.AddWithValue("$r", ratingFilter);
}
cmd.Parameters.AddWithValue("$lim", cap);
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read() && hits.Count < cap)
{
Add(reader);
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory books LIKE: {ex.Message}");
}
}
}
return new JArray(hits.Take(cap));
}
}
+2 -39
View File
@@ -44,47 +44,10 @@ public sealed partial class AssistentMemory
public void SetTrainSampleAgentLinked(string id, bool linked) public void SetTrainSampleAgentLinked(string id, bool linked)
{ {
if (string.IsNullOrWhiteSpace(id)) // Training UI removed — no-op.
{
return;
}
lock (_lock)
{
EnsureTrainingReady();
if (!HasColumn("train_samples", "agent_linked"))
{
return;
}
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "UPDATE train_samples SET agent_linked = $v, updated_at = $u WHERE id = $id";
cmd.Parameters.AddWithValue("$v", linked ? 1 : 0);
cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.Parameters.AddWithValue("$id", id.Trim());
cmd.ExecuteNonQuery();
}
} }
public int CountAgentLinkedTrainSamples() public int CountAgentLinkedTrainSamples() => 0;
{
lock (_lock)
{
try
{
EnsureTrainingReady();
}
catch
{
return 0;
}
if (!HasColumn("train_samples", "agent_linked"))
{
return 0;
}
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM train_samples WHERE agent_linked = 1";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
public async Task<bool> LinkTrainSampleToAgentAsync(string baseUrl, JObject sample, string embedModel) public async Task<bool> LinkTrainSampleToAgentAsync(string baseUrl, JObject sample, string embedModel)
{ {
-385
View File
@@ -1,385 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Training datasets, samples, and job metadata in assistent.sqlite.</summary>
public sealed partial class AssistentMemory
{
public const string KvHfDatasetCache = "hf_dataset_cache";
void EnsureTrainingSchema()
{
Exec(
"""
CREATE TABLE IF NOT EXISTS train_datasets (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
meta_json TEXT
);
CREATE TABLE IF NOT EXISTS train_samples (
id TEXT PRIMARY KEY,
dataset_id TEXT NOT NULL DEFAULT 'default',
source TEXT NOT NULL DEFAULT 'manual',
chat_id TEXT,
persona TEXT,
pack TEXT,
hf_repo TEXT,
messages_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'draft',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(dataset_id) REFERENCES train_datasets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_train_samples_status ON train_samples(status);
CREATE INDEX IF NOT EXISTS idx_train_samples_dataset ON train_samples(dataset_id);
CREATE TABLE IF NOT EXISTS train_jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
config_json TEXT,
base_model TEXT,
output_name TEXT,
log_path TEXT,
progress_json TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_train_jobs_status ON train_jobs(status);
""");
if (!HasColumn("train_samples", "agent_linked"))
{
Exec("ALTER TABLE train_samples ADD COLUMN agent_linked INTEGER NOT NULL DEFAULT 0");
}
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "INSERT OR IGNORE INTO train_datasets(id, title, created_at, updated_at) VALUES('default', 'Default', $u, $u)";
cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.ExecuteNonQuery();
}
public List<JObject> ListTrainSamples(string status = null, string persona = null, string datasetId = null, int limit = 200)
{
lock (_lock)
{
EnsureTrainingReady();
int take = Math.Clamp(limit, 1, 2000);
List<string> where = [];
if (!string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
{
where.Add("status = $status");
}
if (!string.IsNullOrWhiteSpace(persona) && !string.Equals(persona, "all", StringComparison.OrdinalIgnoreCase))
{
where.Add("persona = $persona");
}
if (!string.IsNullOrWhiteSpace(datasetId))
{
where.Add("dataset_id = $ds");
}
bool hasAgentLinked = HasColumn("train_samples", "agent_linked");
string sql = hasAgentLinked
? "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at, agent_linked FROM train_samples"
: "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at FROM train_samples";
if (where.Count > 0)
{
sql += " WHERE " + string.Join(" AND ", where);
}
sql += " ORDER BY updated_at DESC LIMIT $lim";
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = sql;
if (where.Any(w => w.Contains("$status")))
{
cmd.Parameters.AddWithValue("$status", status.Trim());
}
if (where.Any(w => w.Contains("$persona")))
{
cmd.Parameters.AddWithValue("$persona", persona.Trim());
}
if (where.Any(w => w.Contains("$ds")))
{
cmd.Parameters.AddWithValue("$ds", datasetId.Trim());
}
cmd.Parameters.AddWithValue("$lim", take);
List<JObject> list = [];
using SqliteDataReader r = cmd.ExecuteReader();
while (r.Read())
{
list.Add(ReadTrainSampleRow(r));
}
return list;
}
}
static JObject ReadTrainSampleRow(SqliteDataReader r)
{
JArray messages = [];
try
{
messages = JArray.Parse(r.GetString(7));
}
catch
{
// ignore
}
bool hasAgentLinked = r.FieldCount > 11;
return new JObject
{
["id"] = r.GetString(0),
["dataset_id"] = r.GetString(1),
["source"] = r.GetString(2),
["chat_id"] = r.IsDBNull(3) ? null : r.GetString(3),
["persona"] = r.IsDBNull(4) ? null : r.GetString(4),
["pack"] = r.IsDBNull(5) ? null : r.GetString(5),
["hf_repo"] = r.IsDBNull(6) ? null : r.GetString(6),
["messages"] = messages,
["status"] = r.GetString(8),
["createdAt"] = r.GetInt64(9),
["updatedAt"] = r.GetInt64(10),
["agent_linked"] = hasAgentLinked && !r.IsDBNull(11) && r.GetInt64(11) != 0,
};
}
public JObject GetTrainSample(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
lock (_lock)
{
EnsureOpen();
bool hasAgentLinked = HasColumn("train_samples", "agent_linked");
string sql = hasAgentLinked
? "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at, agent_linked FROM train_samples WHERE id = $id LIMIT 1"
: "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at FROM train_samples WHERE id = $id LIMIT 1";
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.Parameters.AddWithValue("$id", id.Trim());
using SqliteDataReader r = cmd.ExecuteReader();
return r.Read() ? ReadTrainSampleRow(r) : null;
}
}
public int ImportTrainSamplesBatch(IEnumerable<JObject> samples)
{
lock (_lock)
{
EnsureTrainingReady();
int imported = 0;
using SqliteTransaction tx = _conn.BeginTransaction();
foreach (JObject sample in samples)
{
if (sample is null)
{
continue;
}
UpsertTrainSampleCore(sample, tx);
imported++;
}
tx.Commit();
return imported;
}
}
public JObject UpsertTrainSample(JObject sample)
{
lock (_lock)
{
EnsureTrainingReady();
return UpsertTrainSampleCore(sample, null);
}
}
JObject UpsertTrainSampleCore(JObject sample, SqliteTransaction tx)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string id = sample["id"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
id = $"ts_{now}_{Guid.NewGuid():N}"[..24];
}
string datasetId = sample["dataset_id"]?.ToString()?.Trim() ?? "default";
JArray messages = sample["messages"] as JArray ?? [];
using SqliteCommand cmd = _conn.CreateCommand();
if (tx is not null)
{
cmd.Transaction = tx;
}
cmd.CommandText =
"""
INSERT INTO train_samples(id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at)
VALUES($id, $ds, $src, $chat, $persona, $pack, $hf, $msg, $status, $c, $u)
ON CONFLICT(id) DO UPDATE SET
dataset_id = excluded.dataset_id,
source = excluded.source,
chat_id = excluded.chat_id,
persona = excluded.persona,
pack = excluded.pack,
hf_repo = excluded.hf_repo,
messages_json = excluded.messages_json,
status = excluded.status,
updated_at = excluded.updated_at
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$ds", datasetId);
cmd.Parameters.AddWithValue("$src", sample["source"]?.ToString() ?? "manual");
cmd.Parameters.AddWithValue("$chat", (object)sample["chat_id"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$persona", (object)sample["persona"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$pack", (object)sample["pack"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$hf", (object)sample["hf_repo"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$msg", messages.ToString(Newtonsoft.Json.Formatting.None));
cmd.Parameters.AddWithValue("$status", sample["status"]?.ToString() ?? "draft");
long created = sample["createdAt"]?.Value<long?>() ?? now;
cmd.Parameters.AddWithValue("$c", created);
cmd.Parameters.AddWithValue("$u", now);
cmd.ExecuteNonQuery();
return new JObject { ["id"] = id, ["updatedAt"] = now };
}
public bool DeleteTrainSample(string id)
{
lock (_lock)
{
EnsureTrainingReady();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "DELETE FROM train_samples WHERE id = $id";
cmd.Parameters.AddWithValue("$id", id ?? "");
return cmd.ExecuteNonQuery() > 0;
}
}
public int CountTrainSamples(string status = null)
{
lock (_lock)
{
EnsureTrainingReady();
using SqliteCommand cmd = _conn.CreateCommand();
if (string.IsNullOrWhiteSpace(status) || string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
{
cmd.CommandText = "SELECT COUNT(*) FROM train_samples";
}
else
{
cmd.CommandText = "SELECT COUNT(*) FROM train_samples WHERE status = $s";
cmd.Parameters.AddWithValue("$s", status.Trim());
}
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
public JObject SaveTrainJob(JObject job)
{
lock (_lock)
{
EnsureOpen();
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string id = job["id"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
id = $"tj_{now}_{Guid.NewGuid():N}"[..24];
}
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
INSERT INTO train_jobs(id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at)
VALUES($id, $kind, $status, $cfg, $base, $out, $log, $prog, $c, $u, $f)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
config_json = excluded.config_json,
log_path = excluded.log_path,
progress_json = excluded.progress_json,
updated_at = excluded.updated_at,
finished_at = excluded.finished_at
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$kind", job["kind"]?.ToString() ?? "qlora");
cmd.Parameters.AddWithValue("$status", job["status"]?.ToString() ?? "pending");
cmd.Parameters.AddWithValue("$cfg", job["config"]?.ToString(Newtonsoft.Json.Formatting.None) ?? job["config_json"]?.ToString() ?? "{}");
cmd.Parameters.AddWithValue("$base", (object)job["base_model"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$out", (object)job["output_name"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$log", (object)job["log_path"]?.ToString() ?? DBNull.Value);
cmd.Parameters.AddWithValue("$prog", (object)(job["progress"]?.ToString(Newtonsoft.Json.Formatting.None) ?? job["progress_json"]?.ToString()) ?? DBNull.Value);
cmd.Parameters.AddWithValue("$c", job["created_at"]?.Value<long?>() ?? job["createdAt"]?.Value<long?>() ?? now);
cmd.Parameters.AddWithValue("$u", now);
cmd.Parameters.AddWithValue("$f", (object)(job["finished_at"]?.Value<long?>() ?? job["finishedAt"]?.Value<long?>()) ?? DBNull.Value);
cmd.ExecuteNonQuery();
return new JObject { ["id"] = id };
}
}
public JObject GetTrainJob(string id)
{
lock (_lock)
{
EnsureTrainingReady();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at FROM train_jobs WHERE id = $id";
cmd.Parameters.AddWithValue("$id", id ?? "");
using SqliteDataReader r = cmd.ExecuteReader();
if (!r.Read())
{
return null;
}
return ReadTrainJobRow(r);
}
}
public JObject GetLastTrainJob()
{
lock (_lock)
{
EnsureTrainingReady();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at "
+ "FROM train_jobs ORDER BY updated_at DESC LIMIT 1";
using SqliteDataReader r = cmd.ExecuteReader();
if (!r.Read())
{
return null;
}
return ReadTrainJobRow(r);
}
}
static JObject ReadTrainJobRow(SqliteDataReader r)
{
return new JObject
{
["id"] = r.GetString(0),
["kind"] = r.GetString(1),
["status"] = r.GetString(2),
["config_json"] = r.IsDBNull(3) ? null : r.GetString(3),
["base_model"] = r.IsDBNull(4) ? null : r.GetString(4),
["output_name"] = r.IsDBNull(5) ? null : r.GetString(5),
["log_path"] = r.IsDBNull(6) ? null : r.GetString(6),
["progress_json"] = r.IsDBNull(7) ? null : r.GetString(7),
["created_at"] = r.GetInt64(8),
["updated_at"] = r.GetInt64(9),
["finished_at"] = r.IsDBNull(10) ? null : r.GetInt64(10),
};
}
public JObject GetActiveTrainJob()
{
lock (_lock)
{
EnsureTrainingReady();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at FROM train_jobs WHERE status IN ('pending','running') ORDER BY updated_at DESC LIMIT 1";
using SqliteDataReader r = cmd.ExecuteReader();
if (!r.Read())
{
return null;
}
return ReadTrainJobRow(r);
}
}
}
+3 -18
View File
@@ -144,14 +144,6 @@ public sealed partial class AssistentMemory : IDisposable
Logs.Debug($"AssistentMemory store schema: {ex.Message}"); Logs.Debug($"AssistentMemory store schema: {ex.Message}");
} }
try try
{
EnsureTrainingSchema();
}
catch (Exception ex)
{
Logs.Error($"AssistentMemory training schema failed: {ex.Message}");
}
try
{ {
EnsureUserPrefsSchema(); EnsureUserPrefsSchema();
} }
@@ -193,17 +185,8 @@ public sealed partial class AssistentMemory : IDisposable
internal void EnsureTrainingReady() internal void EnsureTrainingReady()
{ {
// Training UI removed in 0.16 — heard examples live in memories only.
EnsureOpen(); EnsureOpen();
if (!HasTable("train_samples"))
{
EnsureTrainingSchema();
}
if (!HasTable("train_samples"))
{
throw new InvalidOperationException(
"Assistent training database unavailable (train_samples). "
+ "Run gpu-rent seed-extensions and restart SwarmUI (≥0.15.6).");
}
} }
void MigratePersonaColumn() void MigratePersonaColumn()
@@ -443,6 +426,7 @@ public sealed partial class AssistentMemory : IDisposable
{ {
TryIndexTags(); TryIndexTags();
TryIndexExamples(); TryIndexExamples();
TryIndexBooks();
return; return;
} }
@@ -478,6 +462,7 @@ public sealed partial class AssistentMemory : IDisposable
Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}"); Logs.Debug($"AssistentMemory seed defer (embed unavailable): {ex.Message}");
TryIndexTags(); TryIndexTags();
TryIndexExamples(); TryIndexExamples();
TryIndexBooks();
return; return;
} }
+4 -2
View File
@@ -339,7 +339,7 @@ public partial class SwarmAssistentExtension
string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString(); string embedModel = raw?["embed_model"]?.ToString() ?? Config.LoadSettings()["embed_model"]?.ToString();
try try
{ {
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops( (string reply, JObject parsed, JArray civitai, JObject knowledge, int systemChars, JObject systemLayers) = await RunChatWithHops(
session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel); session, root, modelName, packName, includeBase, contextJson, userMessages, personaId: persona, skillIds: skills, embedModel: embedModel);
JObject result = new() JObject result = new()
{ {
@@ -350,6 +350,7 @@ public partial class SwarmAssistentExtension
["persona"] = persona, ["persona"] = persona,
["raw"] = parsed, ["raw"] = parsed,
["civitai_results"] = civitai, ["civitai_results"] = civitai,
["knowledge"] = knowledge,
["system_chars"] = systemChars, ["system_chars"] = systemChars,
["system_layers"] = systemLayers, ["system_layers"] = systemLayers,
}; };
@@ -409,7 +410,7 @@ public partial class SwarmAssistentExtension
}, API.WebsocketTimeout); }, API.WebsocketTimeout);
} }
} }
(string reply, JObject parsed, JArray civitai, int systemChars, JObject systemLayers) = await RunChatWithHops( (string reply, JObject parsed, JArray civitai, JObject knowledge, int systemChars, JObject systemLayers) = await RunChatWithHops(
session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel); session, root, modelName, packName, includeBase, contextJson, userMessages, OnDelta, OnHopStart, persona, skills, embedModel);
JObject done = new() JObject done = new()
{ {
@@ -421,6 +422,7 @@ public partial class SwarmAssistentExtension
["persona"] = persona, ["persona"] = persona,
["raw"] = parsed, ["raw"] = parsed,
["civitai_results"] = civitai, ["civitai_results"] = civitai,
["knowledge"] = knowledge,
["system_chars"] = systemChars, ["system_chars"] = systemChars,
["system_layers"] = systemLayers, ["system_layers"] = systemLayers,
}; };
+4
View File
@@ -313,6 +313,10 @@ public partial class SwarmAssistentExtension
{ {
return "ask_inventory"; return "ask_inventory";
} }
if ((AskContains(patch, "knowledge") || ActionsContain(patch, "lookup_knowledge")) && !Skip("ask_knowledge"))
{
return "ask_knowledge";
}
if ((AskContains(patch, "examples") || ActionsContain(patch, "lookup_examples")) && !Skip("ask_examples")) if ((AskContains(patch, "examples") || ActionsContain(patch, "lookup_examples")) && !Skip("ask_examples"))
{ {
return "ask_examples"; return "ask_examples";
-181
View File
@@ -1,181 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Link training dataset samples to the live agent as retrievable &quot;heard&quot; examples.</summary>
public partial class SwarmAssistentExtension
{
string MemoryEmbedForTraining(string personaId = null)
{
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
return Config.LoadSettings()["embed_model"]?.ToString()
?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
?? "nomic-embed-text";
}
string MemoryBaseForTraining(JObject raw = null)
=> MemoryBaseUrl(raw?["base_url"]?.ToString());
public async Task<JObject> AssistentGetDatasetAgentSettings(Session session)
{
await Task.CompletedTask;
try
{
JObject settings = Config.LoadTrainingAgent();
return new JObject
{
["success"] = true,
["settings"] = settings,
["linked"] = Memory.CountAgentLinkedTrainSamples(),
["approved"] = Memory.CountTrainSamples("approved"),
};
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentSaveDatasetAgentSettings(Session session, JObject settings)
{
await Task.CompletedTask;
if (settings is null)
{
return new JObject { ["error"] = "settings required" };
}
Config.SaveTrainingAgent(settings);
return new JObject { ["success"] = true, ["settings"] = Config.LoadTrainingAgent() };
}
public async Task<JObject> AssistentLinkTrainSampleToAgent(Session session, string id, JObject raw = null)
{
if (string.IsNullOrWhiteSpace(id))
{
return new JObject { ["error"] = "id required" };
}
JObject sample = Memory.GetTrainSample(id.Trim());
if (sample is null)
{
return new JObject { ["error"] = "sample not found" };
}
if (!string.Equals(sample["status"]?.ToString(), "approved", StringComparison.OrdinalIgnoreCase))
{
return new JObject { ["error"] = "only approved samples can be linked to the agent" };
}
try
{
string embed = MemoryEmbedForTraining(sample["persona"]?.ToString());
bool ok = await Memory.LinkTrainSampleToAgentAsync(MemoryBaseForTraining(raw), sample, embed);
return new JObject { ["success"] = ok, ["linked"] = Memory.CountAgentLinkedTrainSamples() };
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentUnlinkTrainSampleFromAgent(Session session, string id)
{
await Task.CompletedTask;
if (string.IsNullOrWhiteSpace(id))
{
return new JObject { ["error"] = "id required" };
}
JObject sample = Memory.GetTrainSample(id.Trim());
if (sample is null)
{
return new JObject { ["error"] = "sample not found" };
}
Memory.UnlinkTrainSampleFromAgent(sample);
return new JObject { ["success"] = true, ["linked"] = Memory.CountAgentLinkedTrainSamples() };
}
public async Task<JObject> AssistentSyncDatasetToAgent(Session session, JObject raw = null)
{
bool approvedOnly = raw?["approved_only"]?.Value<bool?>() ?? true;
bool relink = raw?["relink"]?.Value<bool?>() ?? false;
string persona = raw?["persona"]?.ToString();
string status = approvedOnly ? "approved" : "all";
List<JObject> samples = Memory.ListTrainSamples(status, persona, null, 2000);
string baseUrl = MemoryBaseForTraining(raw);
int linked = 0;
int skipped = 0;
List<string> errors = [];
foreach (JObject sample in samples)
{
bool already = sample["agent_linked"]?.Value<bool?>() ?? false;
if (already && !relink)
{
skipped++;
continue;
}
if (!string.Equals(sample["status"]?.ToString(), "approved", StringComparison.OrdinalIgnoreCase))
{
skipped++;
continue;
}
try
{
string embed = MemoryEmbedForTraining(sample["persona"]?.ToString());
if (await Memory.LinkTrainSampleToAgentAsync(baseUrl, sample, embed))
{
linked++;
}
else
{
skipped++;
}
}
catch (Exception ex)
{
errors.Add($"{sample["id"]}: {ex.Message}");
}
}
return new JObject
{
["success"] = true,
["linked_now"] = linked,
["skipped"] = skipped,
["total_linked"] = Memory.CountAgentLinkedTrainSamples(),
["errors"] = new JArray(errors.Take(8)),
};
}
async Task TryAutoLinkTrainSample(Session session, JObject sample, JObject raw = null)
{
if (sample is null || Memory is null)
{
return;
}
JObject agent = Config.LoadTrainingAgent();
if (agent["enabled"]?.Value<bool?>() == false)
{
return;
}
if (agent["auto_link_on_approve"]?.Value<bool?>() == false)
{
return;
}
string status = sample["status"]?.ToString() ?? "";
if (!string.Equals(status, "approved", StringComparison.OrdinalIgnoreCase))
{
Memory.UnlinkTrainSampleFromAgent(sample);
return;
}
try
{
string embed = MemoryEmbedForTraining(sample["persona"]?.ToString());
await Memory.LinkTrainSampleToAgentAsync(MemoryBaseForTraining(raw), sample, embed);
}
catch (Exception ex)
{
Logs.Debug($"TryAutoLinkTrainSample: {ex.Message}");
}
}
}
-456
View File
@@ -1,456 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Training samples, dataset import/export, Ollama Modelfile builder.</summary>
public partial class SwarmAssistentExtension
{
static string TrainingRoot()
{
string root = Path.Combine(DataRoot(), "Assistent", "training");
Directory.CreateDirectory(root);
Directory.CreateDirectory(Path.Combine(root, "datasets"));
Directory.CreateDirectory(Path.Combine(root, "jobs"));
Directory.CreateDirectory(Path.Combine(root, "adapters"));
return root;
}
public async Task<JObject> AssistentListTrainSamples(Session session, string status = null, string persona = null, int limit = 200)
{
await Task.CompletedTask;
try
{
List<JObject> list = Memory.ListTrainSamples(status, persona, null, limit);
return new JObject
{
["success"] = true,
["samples"] = new JArray(list),
["approved"] = Memory.CountTrainSamples("approved"),
["draft"] = Memory.CountTrainSamples("draft"),
["total"] = Memory.CountTrainSamples(null),
};
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentUpsertTrainSample(Session session, JObject raw)
{
await Task.CompletedTask;
if (raw is null)
{
return new JObject { ["error"] = "body required" };
}
try
{
JObject saved = Memory.UpsertTrainSample(raw);
JObject full = Memory.GetTrainSample(saved["id"]?.ToString()) ?? raw;
await TryAutoLinkTrainSample(session, full, raw);
return new JObject { ["success"] = true, ["sample"] = full };
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentDeleteTrainSample(Session session, string id)
{
await Task.CompletedTask;
if (string.IsNullOrWhiteSpace(id))
{
return new JObject { ["error"] = "id required" };
}
JObject sample = Memory.GetTrainSample(id.Trim());
bool ok = Memory.DeleteTrainSample(id.Trim());
if (ok && sample is not null)
{
Memory.UnlinkTrainSampleFromAgent(sample);
}
return new JObject { ["success"] = true, ["deleted"] = ok };
}
public async Task<JObject> AssistentBuildDatasetFromChats(Session session, bool approved_only = false)
{
await Task.CompletedTask;
try
{
List<JObject> chats = Memory.ListChats(withMessages: true, limit: AssistentMemory.MaxChatsStored);
int added = 0;
foreach (JObject chat in chats)
{
JArray messages = chat["messages"] as JArray ?? [];
for (int i = 0; i < messages.Count - 1; i++)
{
if (messages[i] is not JObject u || messages[i + 1] is not JObject a)
{
continue;
}
if (!string.Equals(u["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!string.Equals(a["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase))
{
continue;
}
Memory.UpsertTrainSample(new JObject
{
["source"] = "chat",
["chat_id"] = chat["id"],
["persona"] = a["persona"] ?? u["persona"],
["pack"] = a["pack"] ?? u["pack"],
["status"] = approved_only ? "approved" : "draft",
["messages"] = new JArray { u.DeepClone(), a.DeepClone() },
});
added++;
}
}
return new JObject { ["success"] = true, ["added"] = added };
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentImportDataset(Session session, JObject raw)
{
await Task.CompletedTask;
string content = raw?["content"]?.ToString();
string format = raw?["format"]?.ToString() ?? "auto";
if (string.IsNullOrWhiteSpace(content))
{
return new JObject { ["error"] = "content required" };
}
try
{
int imported = 0;
string fmt = (format ?? "auto").Trim().ToLowerInvariant();
List<JObject> records = ParseDatasetContent(content, fmt);
foreach (JObject rec in records)
{
JArray messages = rec["messages"] as JArray;
if (messages is null || messages.Count == 0)
{
continue;
}
Memory.UpsertTrainSample(new JObject
{
["source"] = "import",
["messages"] = messages,
["status"] = "draft",
});
imported++;
}
return new JObject { ["success"] = true, ["imported"] = imported };
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
static List<JObject> ParseDatasetContent(string content, string format)
{
List<JObject> list = [];
string trimmed = content.Trim();
if (trimmed.StartsWith('['))
{
JArray arr = JArray.Parse(trimmed);
foreach (JToken t in arr)
{
if (t is JObject o)
{
JArray msgs = ExtractMessagesFromRecord(o);
if (msgs != null)
{
list.Add(new JObject { ["messages"] = msgs });
}
}
}
return list;
}
if (format == "csv" || LooksLikeCsv(trimmed))
{
return ParseCsvDataset(trimmed);
}
foreach (string line in trimmed.Split('\n'))
{
string ln = line.Trim();
if (string.IsNullOrWhiteSpace(ln))
{
continue;
}
try
{
JObject o = JObject.Parse(ln);
JArray msgs = ExtractMessagesFromRecord(o);
if (msgs != null)
{
list.Add(new JObject { ["messages"] = msgs });
}
}
catch
{
// skip bad line
}
}
return list;
}
static bool LooksLikeCsv(string s) => s.Contains(',') && s.Contains('\n') && !s.TrimStart().StartsWith('{');
static List<JObject> ParseCsvDataset(string csv)
{
List<JObject> list = [];
string[] lines = csv.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
if (lines.Length < 2)
{
return list;
}
string[] headers = lines[0].Split(',').Select(h => h.Trim().Trim('"')).ToArray();
int promptIdx = Array.FindIndex(headers, h => h.Equals("prompt", StringComparison.OrdinalIgnoreCase) || h.Equals("question", StringComparison.OrdinalIgnoreCase) || h.Equals("instruction", StringComparison.OrdinalIgnoreCase));
int respIdx = Array.FindIndex(headers, h => h.Equals("response", StringComparison.OrdinalIgnoreCase) || h.Equals("answer", StringComparison.OrdinalIgnoreCase) || h.Equals("output", StringComparison.OrdinalIgnoreCase) || h.Equals("completion", StringComparison.OrdinalIgnoreCase));
if (promptIdx < 0 || respIdx < 0)
{
return list;
}
for (int i = 1; i < lines.Length; i++)
{
string[] cols = SplitCsvLine(lines[i]);
if (cols.Length <= Math.Max(promptIdx, respIdx))
{
continue;
}
list.Add(new JObject
{
["messages"] = new JArray
{
new JObject { ["role"] = "user", ["content"] = cols[promptIdx] },
new JObject { ["role"] = "assistant", ["content"] = cols[respIdx] },
},
});
}
return list;
}
static string[] SplitCsvLine(string line)
{
List<string> parts = [];
StringBuilder cur = new();
bool inQ = false;
foreach (char c in line)
{
if (c == '"')
{
inQ = !inQ;
continue;
}
if (c == ',' && !inQ)
{
parts.Add(cur.ToString().Trim());
cur.Clear();
continue;
}
cur.Append(c);
}
parts.Add(cur.ToString().Trim());
return parts.ToArray();
}
static JArray ExtractMessagesFromRecord(JObject o)
{
if (o["messages"] is JArray msgs)
{
return NormalizeMessagesArray(msgs);
}
if (o["conversations"] is JArray conv)
{
return ConvertHfRowToMessages(new JObject { ["conversations"] = conv }, new JObject { ["kind"] = "conversations" }, null);
}
if (o["instruction"] != null && o["output"] != null)
{
return ConvertHfRowToMessages(o, new JObject { ["kind"] = "alpaca" }, null);
}
if (o["prompt"] != null && (o["response"] != null || o["completion"] != null))
{
return ConvertHfRowToMessages(o, new JObject { ["kind"] = "prompt_response", ["response_col"] = o["response"] != null ? "response" : "completion" }, null);
}
return null;
}
public async Task<JObject> AssistentExportDataset(Session session, string status = "approved", string format = "jsonl")
{
await Task.CompletedTask;
try
{
List<JObject> samples = Memory.ListTrainSamples(status, null, null, 5000);
StringBuilder sb = new();
int exported = 0;
foreach (JObject s in samples)
{
JArray messages = s["messages"] as JArray ?? [];
if (messages.Count == 0)
{
continue;
}
exported++;
if (string.Equals(format, "sharegpt", StringComparison.OrdinalIgnoreCase))
{
JArray conv = [];
foreach (JToken m in messages)
{
if (m is not JObject mo)
{
continue;
}
string role = mo["role"]?.ToString() ?? "user";
conv.Add(new JObject
{
["from"] = role == "assistant" ? "gpt" : "human",
["value"] = mo["content"]?.ToString() ?? "",
});
}
sb.AppendLine(new JObject { ["conversations"] = conv }.ToString(Newtonsoft.Json.Formatting.None));
}
else
{
sb.AppendLine(new JObject { ["messages"] = messages }.ToString(Newtonsoft.Json.Formatting.None));
}
}
string path = Path.Combine(TrainingRoot(), "datasets", $"export_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.jsonl");
await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8);
return new JObject { ["success"] = true, ["path"] = path, ["count"] = exported, ["content"] = sb.ToString() };
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message };
}
}
public async Task<JObject> AssistentCreateOllamaModel(Session session, JObject raw)
{
if (raw is null)
{
return new JObject { ["error"] = "body required" };
}
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
string baseModel = raw["base_model"]?.ToString()?.Trim();
string name = raw["name"]?.ToString()?.Trim();
string system = raw["system"]?.ToString() ?? "";
int shots = raw["shots"]?.Value<int?>() ?? 8;
if (string.IsNullOrWhiteSpace(baseModel) || string.IsNullOrWhiteSpace(name))
{
return new JObject { ["error"] = "base_model and name required" };
}
if (string.IsNullOrWhiteSpace(system))
{
string persona = AssistentConfig.SafeId(raw["persona"]?.ToString()) ?? Config.DefaultPersonaId();
system = Config.LoadCorePrompt(persona) + "\n\n" + Config.RenderIdentityBlock(persona, includeAllShelves: true);
}
StringBuilder mf = new();
mf.AppendLine($"FROM {baseModel}");
mf.AppendLine($"SYSTEM \"\"\"{system}\"\"\"");
List<JObject> samples = Memory.ListTrainSamples("approved", null, null, Math.Clamp(shots, 0, 32));
foreach (JObject s in samples.Take(shots))
{
JArray messages = s["messages"] as JArray ?? [];
foreach (JToken m in messages)
{
if (m is not JObject mo)
{
continue;
}
string role = mo["role"]?.ToString() ?? "user";
string content = mo["content"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(content))
{
continue;
}
mf.AppendLine($"MESSAGE {role} \"\"\"{content.Replace("\"\"\"", "\"\"\"\"\"\"\"")}\"\"\"");
}
}
if (raw["num_ctx"] != null)
{
mf.AppendLine($"PARAMETER num_ctx {raw["num_ctx"]}");
}
if (raw["temperature"] != null)
{
mf.AppendLine($"PARAMETER temperature {raw["temperature"]}");
}
string modelfilePath = Path.Combine(TrainingRoot(), "jobs", $"modelfile_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.Modelfile");
Directory.CreateDirectory(Path.GetDirectoryName(modelfilePath)!);
await File.WriteAllTextAsync(modelfilePath, mf.ToString(), Encoding.UTF8);
try
{
JObject payload = new()
{
["name"] = name,
["modelfile"] = mf.ToString(),
["stream"] = false,
};
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
using HttpResponseMessage resp = await HttpClient.PostAsync($"{baseUrl}/api/create", content);
string body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
return new JObject { ["error"] = $"Ollama create HTTP {(int)resp.StatusCode}: {Clip(body, 400)}", ["modelfile_path"] = modelfilePath };
}
return new JObject { ["success"] = true, ["name"] = name, ["modelfile_path"] = modelfilePath, ["ollama"] = body };
}
catch (Exception ex)
{
return new JObject { ["error"] = ex.Message, ["modelfile_path"] = modelfilePath };
}
}
public async Task<JObject> AssistentGetTrainJob(Session session, string id = null)
{
await Task.CompletedTask;
JObject job = string.IsNullOrWhiteSpace(id) ? Memory.GetActiveTrainJob() : Memory.GetTrainJob(id);
JObject lastJob = Memory.GetLastTrainJob();
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
{
["success"] = true,
["job"] = job,
["last_job"] = lastJob,
["training_active"] = TrainingJobManager.IsRunning,
["progress"] = TrainingJobManager.IsRunning ? TrainingJobManager.GetProgress() : null,
};
}
public async Task<JObject> AssistentSaveRunnerSettings(Session session, JObject settings)
{
await Task.CompletedTask;
if (settings is null)
{
return new JObject { ["error"] = "settings required" };
}
Config.SaveTrainingRunner(settings);
return new JObject { ["success"] = true };
}
public async Task<JObject> AssistentGetRunnerSettings(Session session)
{
await Task.CompletedTask;
return new JObject { ["success"] = true, ["settings"] = Config.LoadTrainingRunner() };
}
}
-607
View File
@@ -1,607 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.WebSockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using SwarmUI.Accounts;
using SwarmUI.Utils;
using SwarmUI.WebAPI;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>QLoRA training job runner with VRAM lock and progress streaming.</summary>
public partial class SwarmAssistentExtension
{
static readonly TrainingJobManager TrainingJobManager = new();
public async Task<JObject> AssistentStartTrainJob(Session session, JObject raw)
{
if (raw is null)
{
return new JObject { ["error"] = "body required" };
}
if (TrainingJobManager.IsRunning)
{
return new JObject { ["error"] = "Тренировка уже идёт" };
}
string hfBase = raw["base_model"]?.ToString()?.Trim();
string outputName = raw["output_name"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(hfBase) || string.IsNullOrWhiteSpace(outputName))
{
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()))
{
hfDataset = NormalizeHfDatasetId(raw["hf_dataset"]?.ToString());
if (hfDataset is null)
{
return new JObject { ["error"] = "invalid hf_dataset id" };
}
hfCheck = await CheckHfDatasetInternal(session, hfDataset, useCache: true);
if (hfCheck["gate"]?.ToString() == "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 };
}
}
JObject runner = Config.LoadTrainingRunner();
string kind = runner["kind"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(kind))
{
kind = "builtin";
}
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
string chatModel = raw["chat_model"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(chatModel))
{
await AssistentParkLlm(session, baseUrl, chatModel);
}
string datasetPath = null;
int exportCount = 0;
if (string.IsNullOrWhiteSpace(hfDataset))
{
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();
string jobId = $"tj_{now}";
string jobDir = Path.Combine(TrainingRoot(), "jobs", jobId);
Directory.CreateDirectory(jobDir);
string configPath = Path.Combine(jobDir, "config.json");
string logPath = Path.Combine(jobDir, "log.txt");
string adapterDir = Path.Combine(TrainingRoot(), "adapters", SanitizeAdapterName(outputName));
Directory.CreateDirectory(adapterDir);
JObject jobConfig = new()
{
["base_model"] = hfBase,
["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,
["hf_dataset"] = hfDataset,
["hf_mapping"] = hfMapping,
["hf_schema"] = hfCheck?["schema"],
["max_samples"] = raw["max_samples"] ?? 0,
["rank"] = raw["rank"] ?? 16,
["alpha"] = raw["alpha"] ?? 32,
["lr"] = raw["lr"] ?? 0.0002,
["epochs"] = raw["epochs"] ?? 3,
["seq_len"] = raw["seq_len"] ?? 2048,
["four_bit"] = raw["four_bit"] ?? true,
["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);
Memory.SaveTrainJob(new JObject
{
["id"] = jobId,
["kind"] = "qlora",
["status"] = "running",
["config"] = jobConfig,
["base_model"] = hfBase,
["output_name"] = outputName,
["log_path"] = logPath,
["created_at"] = now,
});
string cmdLine = BuildRunnerCommand(runner, configPath, logPath, jobDir);
bool started = TrainingJobManager.Start(this, session, jobId, cmdLine, logPath, baseUrl, chatModel, GetHfToken(session));
if (!started)
{
Memory.SaveTrainJob(new JObject { ["id"] = jobId, ["status"] = "failed", ["progress"] = new JObject { ["error"] = "process start failed" } });
return new JObject { ["error"] = "Не удалось запустить процесс тренировки" };
}
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();
}
string BuildRunnerCommand(JObject runner, string configPath, string logPath, string workDir)
{
string python = runner["python"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(python))
{
python = "python";
}
string kind = runner["kind"]?.ToString()?.Trim() ?? "builtin";
string custom = runner["cmd"]?.ToString()?.Trim();
string scriptPath = Path.Combine(FilePath, "scripts", "train_qlora.py");
if (string.Equals(kind, "custom", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(custom))
{
return custom
.Replace("{python}", python, StringComparison.OrdinalIgnoreCase)
.Replace("{config}", configPath, StringComparison.OrdinalIgnoreCase)
.Replace("{log}", logPath, StringComparison.OrdinalIgnoreCase)
.Replace("{workdir}", workDir, StringComparison.OrdinalIgnoreCase);
}
return $"\"{python}\" \"{scriptPath}\" --config \"{configPath}\" --log \"{logPath}\"";
}
public async Task<JObject> AssistentCancelTrainJob(Session session, string id = null)
{
await Task.CompletedTask;
TrainingJobManager.Cancel();
string jobId = id ?? TrainingJobManager.CurrentJobId;
if (!string.IsNullOrWhiteSpace(jobId))
{
Memory.SaveTrainJob(new JObject
{
["id"] = jobId,
["status"] = "cancelled",
["finished_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
}
return new JObject { ["success"] = true, ["cancelled"] = true };
}
public async Task<JObject> AssistentTrainWS(Session session, WebSocket ws, JObject raw)
{
await Task.CompletedTask;
try
{
while (TrainingJobManager.IsRunning && ws.State == System.Net.WebSockets.WebSocketState.Open)
{
JObject progress = TrainingJobManager.GetProgress();
string msg = progress.ToString(Newtonsoft.Json.Formatting.None);
await ws.SendAsync(Encoding.UTF8.GetBytes(msg), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
await Task.Delay(800);
}
JObject final = TrainingJobManager.GetProgress();
final["done"] = true;
await ws.SendAsync(Encoding.UTF8.GetBytes(final.ToString(Newtonsoft.Json.Formatting.None)), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (Exception ex)
{
Logs.Debug($"AssistentTrainWS: {ex.Message}");
}
return new JObject { ["success"] = true };
}
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();
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
{
["id"] = jobId,
["status"] = finalStatus,
["finished_at"] = now,
["progress"] = progress,
});
if (!string.IsNullOrWhiteSpace(chatModel))
{
await AssistentWarmLlm(session, baseUrl, chatModel);
}
TrainingJobManager.ClearRunning();
}
async Task<JObject> RegisterAdapterPipeline(Session session, string baseUrl, string outputName, string adapterDir, JObject jobConfig, JObject runner)
{
string safetensors = Directory.GetFiles(adapterDir, "adapter_model.safetensors").FirstOrDefault();
if (string.IsNullOrWhiteSpace(safetensors))
{
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();
mf.AppendLine($"FROM {ollamaBase}");
mf.AppendLine($"ADAPTER {adapterGguf.Replace("\\", "/")}");
JObject payload = new()
{
["name"] = outputName,
["modelfile"] = mf.ToString(),
["stream"] = false,
};
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);
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
{
static readonly Regex LossRe = new(@"loss[:\s]+([0-9.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex StepRe = new(@"step\s+(\d+)\s*/\s*(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
Process _process;
readonly object _lock = new();
JObject _progress = new() { ["status"] = "idle" };
string _logPath;
SwarmAssistentExtension _ext;
Session _session;
string _jobId;
string _baseUrl;
string _chatModel;
long _lastProgressSaveMs;
int _lastSavedStep = -1;
public bool IsRunning { get; private set; }
public string CurrentJobId => _jobId;
public bool Start(SwarmAssistentExtension ext, Session session, string jobId, string commandLine, string logPath, string baseUrl, string chatModel, string hfToken)
{
lock (_lock)
{
if (IsRunning)
{
return false;
}
_ext = ext;
_session = session;
_jobId = jobId;
_logPath = logPath;
_baseUrl = baseUrl;
_chatModel = chatModel;
_lastProgressSaveMs = 0;
_lastSavedStep = -1;
_progress = new JObject { ["status"] = "running", ["step"] = 0, ["loss"] = null, ["log"] = "" };
try
{
ProcessStartInfo psi = new()
{
FileName = "cmd.exe",
Arguments = $"/c {commandLine}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(logPath) ?? Environment.CurrentDirectory,
};
if (!string.IsNullOrWhiteSpace(hfToken))
{
psi.Environment["HF_TOKEN"] = hfToken;
psi.Environment["HUGGING_FACE_HUB_TOKEN"] = hfToken;
}
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
_process.ErrorDataReceived += (_, e) => AppendLog(e.Data);
_process.Exited += async (_, _) => await OnExited();
_process.Start();
_process.BeginOutputReadLine();
_process.BeginErrorReadLine();
IsRunning = true;
return true;
}
catch (Exception ex)
{
_progress["error"] = ex.Message;
IsRunning = false;
return false;
}
}
}
void AppendLog(string line)
{
if (string.IsNullOrWhiteSpace(line))
{
return;
}
lock (_lock)
{
try
{
File.AppendAllText(_logPath, line + Environment.NewLine);
}
catch
{
// ignore
}
string prev = _progress["log"]?.ToString() ?? "";
string combined = prev + line + "\n";
if (combined.Length > 12000)
{
combined = combined[^12000..];
}
_progress["log"] = combined;
Match lossM = LossRe.Match(line);
if (lossM.Success)
{
_progress["loss"] = lossM.Groups[1].Value;
}
Match stepM = StepRe.Match(line);
if (stepM.Success)
{
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;
}
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
{
["id"] = _jobId,
["status"] = "running",
["progress"] = _progress,
});
}
catch
{
// ignore
}
}
async Task OnExited()
{
bool ok = false;
JObject jobConfig = new();
lock (_lock)
{
ok = _process?.ExitCode == 0;
IsRunning = false;
_progress["status"] = ok ? "completed" : "failed";
_progress["exit_code"] = _process?.ExitCode;
}
if (_ext != null)
{
JObject job = _ext.Memory.GetTrainJob(_jobId);
try
{
string cfgRaw = job?["config_json"]?.ToString();
if (!string.IsNullOrWhiteSpace(cfgRaw))
{
jobConfig = JObject.Parse(cfgRaw);
}
}
catch
{
// ignore
}
JObject runner = _ext.Config.LoadTrainingRunner();
await _ext.FinishTrainJobAsync(_jobId, ok, _logPath, _session, _baseUrl, _chatModel, jobConfig, runner);
}
}
public void Cancel()
{
lock (_lock)
{
try
{
if (_process != null && !_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch
{
// ignore
}
IsRunning = false;
_progress["status"] = "cancelled";
}
}
public JObject GetProgress()
{
lock (_lock)
{
return (JObject)_progress.DeepClone();
}
}
public void ClearRunning()
{
lock (_lock)
{
IsRunning = false;
}
}
}
+1 -1
View File
@@ -8,6 +8,6 @@
"snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed", "snapshot_generate", "select_slot", "aspect", "images", "batch", "vary", "lock_seed",
"creativity", "intensity", "complexity", "movement", "creativity", "intensity", "complexity", "movement",
"clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls", "clear_prompt_images", "slot_to_prompt_image", "pack", "persona", "controls",
"inventory_query", "variants" "inventory_query", "knowledge_query", "example_query", "knowledge_rating", "example_rating", "variants"
] ]
} }
+6
View File
@@ -0,0 +1,6 @@
{
"id": "knowledge",
"title": "Books + knowledge search",
"default": false,
"prompt_file": "knowledge.md"
}
+22
View File
@@ -0,0 +1,22 @@
# Knowledge skill
Attached **books** are read-only FTS corpora (Civitai prompts, RU fic style, …). They are **not** mutable memory.
## When to search
- User asks for reference prompts, scene wording, style/tone, or «как на Civitai».
- You need concrete tag/prompt patterns before generating.
## How to search (server hop)
```json
{ "ask": ["knowledge"], "knowledge_query": "redhead stockings cinematic window light" }
```
Optional: `knowledge_rating` (`pg`, `pg13`, …). Legacy `ask:["examples"]` + `example_query` still works for Civitai-only.
## Rules
- **Remix** — never paste long verbatim from books.
- Pure chat / opinions: **prose only**, no JSON.
- Do **not** emit `generate:true` on knowledge Q&A turns.
+1 -1
View File
@@ -6,7 +6,7 @@ You have five memory tools:
2. **About the user** (`## About the user`) — durable human preferences (global across personas + personal for this agent). Tunable weight in settings. Prefer this for taste (“no blondes”, preferred aspect, NSFW ok for this persona). 2. **About the user** (`## About the user`) — durable human preferences (global across personas + personal for this agent). Tunable weight in settings. Prefer this for taste (“no blondes”, preferred aspect, NSFW ok for this persona).
3. **Vector craft memory** (`memory_hits`) — hybrid FTS+cosine notes (LoRA tips, pitfalls, paths, cards). Shared + this persona; personal overwrites shared on the same `kind`+`key`. 3. **Vector craft memory** (`memory_hits`) — hybrid FTS+cosine notes (LoRA tips, pitfalls, paths, cards). Shared + this persona; personal overwrites shared on the same `kind`+`key`.
4. **Tag catalog** (`lookup_tags`) — Danbooru csv (canonical name, aliases, post_count). **Not** RAG. Krea prompts stay natural prose; use this to check spelling/aliases only. 4. **Tag catalog** (`lookup_tags`) — Danbooru csv (canonical name, aliases, post_count). **Not** RAG. Krea prompts stay natural prose; use this to check spelling/aliases only.
5. **Civitai examples** (`ask: examples` / `lookup_examples`) — popular Krea 2 prompts indexed with FTS (no embeddings). Use when you want a **reference** for how others phrased a scene — remix, do not copy 1:1. 5. **Civitai / knowledge books** (`ask: knowledge` / `ask: examples`) — FTS over attached books (gpu-rent seeded) and legacy Civitai examples. Remix, do not copy 1:1.
## Priority ## Priority
-49
View File
@@ -1,49 +0,0 @@
{
"hf_models": [
{
"id": "qwen2.5-7b-instruct",
"title": "Qwen2.5 7B Instruct",
"hf_id": "Qwen/Qwen2.5-7B-Instruct",
"ollama_hint": "qwen2.5:7b-instruct",
"default_output": "assistent-qwen25-7b:v1",
"rank": 16,
"seq_len": 2048
},
{
"id": "qwen2.5-3b-instruct",
"title": "Qwen2.5 3B Instruct",
"hf_id": "Qwen/Qwen2.5-3B-Instruct",
"ollama_hint": "qwen2.5:3b-instruct",
"default_output": "assistent-qwen25-3b:v1",
"rank": 16,
"seq_len": 2048
},
{
"id": "llama-3.2-3b-instruct",
"title": "Llama 3.2 3B Instruct",
"hf_id": "meta-llama/Llama-3.2-3B-Instruct",
"ollama_hint": "llama3.2:3b-instruct",
"default_output": "assistent-llama32-3b:v1",
"rank": 16,
"seq_len": 2048
},
{
"id": "llama-3.1-8b-instruct",
"title": "Llama 3.1 8B Instruct",
"hf_id": "meta-llama/Llama-3.1-8B-Instruct",
"ollama_hint": "llama3.1:8b-instruct",
"default_output": "assistent-llama31-8b:v1",
"rank": 16,
"seq_len": 2048
},
{
"id": "phi-3-mini-instruct",
"title": "Phi-3 Mini 4K Instruct",
"hf_id": "microsoft/Phi-3-mini-4k-instruct",
"ollama_hint": "phi3:mini",
"default_output": "assistent-phi3-mini:v1",
"rank": 16,
"seq_len": 2048
}
]
}
+7 -25
View File
@@ -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.15.17"; Version = "0.16.0";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"]; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "knowledge", "heard", "books"];
} }
public override void OnInit() public override void OnInit()
@@ -64,6 +64,10 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentGetMemory, false, PermUse); API.RegisterAPICall(AssistentGetMemory, false, PermUse);
API.RegisterAPICall(AssistentLookupTags, false, PermUse); API.RegisterAPICall(AssistentLookupTags, false, PermUse);
API.RegisterAPICall(AssistentLookupExamples, false, PermUse); API.RegisterAPICall(AssistentLookupExamples, false, PermUse);
API.RegisterAPICall(AssistentListKnowledgeCatalog, false, PermUse);
API.RegisterAPICall(AssistentSearchKnowledge, false, PermUse);
API.RegisterAPICall(AssistentGetKnowledgeAttach, false, PermUse);
API.RegisterAPICall(AssistentSaveKnowledgeAttach, true, PermUse);
API.RegisterAPICall(AssistentSaveControls, true, PermUse); API.RegisterAPICall(AssistentSaveControls, true, PermUse);
API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse); API.RegisterAPICall(AssistentGetPersonaShelves, false, PermUse);
API.RegisterAPICall(AssistentClonePersona, true, PermUse); API.RegisterAPICall(AssistentClonePersona, true, PermUse);
@@ -77,29 +81,7 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentForgetUserPref, true, PermUse); API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse); API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
API.RegisterAPICall(AssistentClearMemory, true, PermUse); API.RegisterAPICall(AssistentClearMemory, true, PermUse);
API.RegisterAPICall(AssistentListTrainSamples, false, PermUse); Logs.Init("Swarm Assistent extension loaded (0.16.0 knowledge books hub)");
API.RegisterAPICall(AssistentUpsertTrainSample, true, PermUse);
API.RegisterAPICall(AssistentDeleteTrainSample, true, PermUse);
API.RegisterAPICall(AssistentBuildDatasetFromChats, false, PermUse);
API.RegisterAPICall(AssistentImportDataset, true, PermUse);
API.RegisterAPICall(AssistentExportDataset, false, PermUse);
API.RegisterAPICall(AssistentCreateOllamaModel, true, PermUse);
API.RegisterAPICall(AssistentSearchHfDatasets, false, PermUse);
API.RegisterAPICall(AssistentCheckHfDataset, false, PermUse);
API.RegisterAPICall(AssistentPreviewHfDataset, false, PermUse);
API.RegisterAPICall(AssistentImportHfDataset, true, PermUse);
API.RegisterAPICall(AssistentStartTrainJob, true, PermUse);
API.RegisterAPICall(AssistentCancelTrainJob, true, PermUse);
API.RegisterAPICall(AssistentGetTrainJob, false, PermUse);
API.RegisterAPICall(AssistentTrainWS, true, PermUse);
API.RegisterAPICall(AssistentSaveRunnerSettings, true, PermUse);
API.RegisterAPICall(AssistentGetRunnerSettings, false, PermUse);
API.RegisterAPICall(AssistentGetDatasetAgentSettings, false, PermUse);
API.RegisterAPICall(AssistentSaveDatasetAgentSettings, true, PermUse);
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
Logs.Init("Swarm Assistent extension loaded (0.15.0 persona packs / dreamer)");
} }
int CfgInt(string key, int fallback) int CfgInt(string key, int fallback)
+8 -153
View File
@@ -10,13 +10,8 @@
</div> </div>
<div class="sa-app-tabs" role="tablist" aria-label="Разделы Assistent"> <div class="sa-app-tabs" role="tablist" aria-label="Разделы Assistent">
<button type="button" class="sa-app-tab sa-app-tab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button> <button type="button" class="sa-app-tab sa-app-tab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
<button type="button" class="sa-app-tab" data-view="train" id="sa_tab_train" role="tab" aria-selected="false">Обучение</button>
<button type="button" class="sa-app-tab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button> <button type="button" class="sa-app-tab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
</div> </div>
<div class="sa-train-banner" id="sa_train_banner" hidden role="status">
<span class="sa-spinner" aria-hidden="true"></span>
<span id="sa_train_banner_text">Идёт тренировка…</span>
</div>
</header> </header>
<div class="sa-views" id="sa_views"> <div class="sa-views" id="sa_views">
<div class="sa-view" id="sa_view_chat"> <div class="sa-view" id="sa_view_chat">
@@ -161,140 +156,6 @@
</div> </div>
</div> </div>
<div class="sa-view" id="sa_view_train" hidden>
<div class="sa-training" id="sa_training">
<div class="sa-training-tabs" role="tablist" aria-label="Разделы обучения">
<button type="button" class="sa-ttab sa-ttab-active" data-ttab="dataset" role="tab" aria-selected="true">Датасет</button>
<button type="button" class="sa-ttab" data-ttab="train" role="tab" aria-selected="false">Тренировка</button>
<button type="button" class="sa-ttab" data-ttab="models" role="tab" aria-selected="false">Модели</button>
</div>
<span class="sa-status sa-train-global-status" id="sa_train_status" role="status"></span>
<div class="sa-training-panes">
<div class="sa-tpane" data-tpane="dataset">
<div class="sa-train-toolbar">
<span class="sa-train-stats" id="sa_train_stats">Одобрено: —</span>
<select id="sa_train_filter_status" class="sa-select" title="Статус">
<option value="all">Все статусы</option>
<option value="approved">Одобренные</option>
<option value="draft">Черновики</option>
<option value="rejected">Отклонённые</option>
</select>
<select id="sa_train_filter_persona" class="sa-select" title="Личность"><option value="all">Все личности</option></select>
<button type="button" class="basic-button" id="sa_btn_train_from_chats">Из чатов</button>
<button type="button" class="basic-button" id="sa_btn_train_import_file">Импорт файла…</button>
<input type="file" id="sa_train_import_file" accept=".jsonl,.json,.csv,text/csv,application/json" hidden />
<button type="button" class="basic-button" id="sa_btn_train_export">Экспорт JSONL</button>
</div>
<div class="sa-agent-heard-panel" id="sa_agent_heard_panel">
<div class="sa-agent-heard-head">
<strong>Услышанное → агент</strong>
<span class="sa-agent-heard-stats" id="sa_agent_heard_stats">Подключено: —</span>
</div>
<p class="sa-agent-heard-hint">Одобренные примеры сразу попадают в RAG агента (без QLoRA). Агент видит их как <code>heard_examples</code> и может запросить через <code>heard_search</code>.</p>
<div class="sa-agent-heard-controls">
<label class="sa-check"><input type="checkbox" id="sa_agent_heard_enabled" checked /> Включено для чата</label>
<label class="sa-check"><input type="checkbox" id="sa_agent_auto_link" checked /> Авто-подключение при одобрении</label>
<label>Примеров в контексте <input type="number" id="sa_agent_heard_quota" min="0" max="8" value="3" /></label>
<button type="button" class="basic-button sa-primary" id="sa_btn_agent_sync">Подключить все одобренные</button>
</div>
</div>
<div class="sa-hf-panel">
<div class="sa-hf-head"><strong>Hugging Face</strong></div>
<div class="sa-hf-search-row">
<input type="search" id="sa_hf_search" class="sa-hf-search" placeholder="Поиск датасетов…" autocomplete="off" />
<button type="button" class="basic-button" id="sa_btn_hf_search">Найти</button>
<label class="sa-check sa-hf-show-all"><input type="checkbox" id="sa_hf_show_all" /> Показать все</label>
</div>
<div class="sa-hf-link-row">
<input type="text" id="sa_hf_link" class="sa-hf-link" placeholder="owner/name или https://huggingface.co/datasets/…" />
<button type="button" class="basic-button" id="sa_btn_hf_check">Проверить</button>
</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-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>
<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>
<button type="button" class="basic-button sa-primary" id="sa_btn_hf_import">Импортировать</button>
</div>
</div>
<div class="sa-train-samples" id="sa_train_samples"></div>
</div>
<div class="sa-tpane" data-tpane="train" hidden>
<div class="sa-train-modes">
<label class="sa-check"><input type="radio" name="sa_train_mode" value="modelfile" checked /> Быстро: Ollama Modelfile</label>
<label class="sa-check"><input type="radio" name="sa_train_mode" value="qlora" /> QLoRA (нужен python-раннер)</label>
</div>
<div class="sa-train-form" id="sa_train_form_modelfile">
<label>Базовая модель (Ollama)
<select id="sa_modelfile_base" class="sa-select"><option value=""></option></select>
</label>
<label>Имя результата <input type="text" id="sa_modelfile_name" placeholder="assistent-neutral:v1" /></label>
<label>Персона для SYSTEM
<select id="sa_modelfile_persona" class="sa-select"><option value="neutral">neutral</option></select>
</label>
<label>Few-shot примеров <input type="number" id="sa_modelfile_shots" min="0" max="32" value="8" /></label>
<label>SYSTEM (редактируемый) <textarea id="sa_modelfile_system" rows="8" spellcheck="false"></textarea></label>
<div class="sa-settings-row sa-knob-row">
<label>num_ctx <input type="number" id="sa_modelfile_num_ctx" min="2048" max="131072" step="1024" value="16384" /></label>
<label>temperature <input type="number" id="sa_modelfile_temp" min="0" max="2" step="0.05" value="0.7" /></label>
</div>
<button type="button" class="basic-button sa-primary" id="sa_btn_modelfile_create">Создать модель</button>
</div>
<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>
<label>HF base model
<select id="sa_qlora_base" class="sa-select"><option value="">— выберите модель —</option></select>
</label>
<label id="sa_qlora_base_custom_row" hidden>Другая модель (HF id)
<input type="text" id="sa_qlora_base_custom" placeholder="org/model-name" />
</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">
<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>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>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>
<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="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" id="sa_btn_qlora_cancel" hidden>Отменить</button>
</div>
<div class="sa-train-progress" id="sa_train_progress" hidden>
<div class="sa-train-progress-bar"><div class="sa-train-progress-fill" id="sa_train_progress_fill"></div></div>
<pre class="sa-train-log" id="sa_train_log"></pre>
</div>
</div>
<div class="sa-tpane" data-tpane="models" hidden>
<p class="sa-settings-hint">После QLoRA/Modelfile — итог здесь и в списке Ollama. Выбери модель в шапке Assistent или ⚙ → Модели.</p>
<div class="sa-train-last-job" id="sa_train_last_job" hidden></div>
<div class="sa-train-models-list" id="sa_train_models_list"></div>
<div class="sa-settings-row">
<button type="button" class="basic-button" id="sa_btn_train_models_refresh">Обновить</button>
</div>
</div>
</div>
</div>
</div>
<div class="sa-view" id="sa_view_settings" hidden> <div class="sa-view" id="sa_view_settings" hidden>
<div class="sa-settings" id="sa_settings"> <div class="sa-settings" id="sa_settings">
<div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек"> <div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек">
@@ -330,24 +191,10 @@
<option value="nomic-embed-text">nomic-embed-text</option> <option value="nomic-embed-text">nomic-embed-text</option>
</select> </select>
</label> </label>
<div class="sa-skills-label">QLoRA-раннер</div>
<label>Python <input type="text" id="sa_runner_python" placeholder="python или полный путь" /></label>
<label>Тип тренера
<select id="sa_runner_kind" class="sa-select">
<option value="builtin" selected>Встроенный (train_qlora.py)</option>
<option value="custom">Custom command</option>
</select>
</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>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="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>
<button type="button" class="basic-button" id="sa_btn_settings_health">Проверить Ollama</button> <button type="button" class="basic-button" id="sa_btn_settings_health">Проверить Ollama</button>
<button type="button" class="basic-button" id="sa_btn_save_runner">Сохранить раннер</button>
</div> </div>
<div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div> <div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div>
</div> </div>
@@ -358,6 +205,14 @@
<div class="sa-persona-preview" id="sa_persona_preview"> <div class="sa-persona-preview" id="sa_persona_preview">
<div class="sa-mem-empty">Выбери личность слева</div> <div class="sa-mem-empty">Выбери личность слева</div>
</div> </div>
<div class="sa-knowledge-panel" id="sa_knowledge_panel" hidden>
<div class="sa-mem-head"><span class="sa-skills-label">Знания (books)</span></div>
<p class="sa-settings-hint" id="sa_knowledge_hint">FTS-корпуса для ask:knowledge. Bundled/pack — только просмотр; overlay — редактируемо.</p>
<div class="sa-knowledge-list" id="sa_knowledge_list"></div>
<div class="sa-settings-row">
<button type="button" class="basic-button sa-primary" id="sa_btn_knowledge_save" hidden>Сохранить attach</button>
</div>
</div>
</div> </div>
<div class="sa-settings-row"> <div class="sa-settings-row">
<button type="button" class="basic-button" id="sa_btn_persona_export">Экспорт</button> <button type="button" class="basic-button" id="sa_btn_persona_export">Экспорт</button>
+92 -75
View File
@@ -5996,70 +5996,16 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
scrollMessagesToBottom(); scrollMessagesToBottom();
} }
function mountCurateButtons(msgEl, meta) { function mountCurateButtons() {
if (!msgEl || msgEl.querySelector('.sa-msg-curate')) { // Training dataset curation removed in 0.16.
return;
}
const wrap = document.createElement('div');
wrap.className = 'sa-msg-curate';
const ok = document.createElement('button');
ok.type = 'button';
ok.className = 'basic-button';
ok.title = 'В датасет (одобрить)';
ok.textContent = '+ датасет';
ok.addEventListener('click', () => curateAssistantMessage(msgEl, 'approved'));
const bad = document.createElement('button');
bad.type = 'button';
bad.className = 'basic-button';
bad.title = 'Отклонить для датасета';
bad.textContent = 'брак';
bad.addEventListener('click', () => curateAssistantMessage(msgEl, 'rejected'));
wrap.appendChild(ok);
wrap.appendChild(bad);
msgEl.appendChild(wrap);
} }
function curateAssistantMessage(msgEl, status) { function curateAssistantMessage() {
const hist = state.history || []; setStatus('Обучение снято — используй books/knowledge');
let asstText = msgEl.querySelector('.sa-msg-body')?.textContent?.trim() || msgEl.textContent?.trim() || '';
let userText = '';
for (let i = hist.length - 1; i >= 0; i--) {
if (hist[i]?.role === 'assistant' && (hist[i].content || '').trim() === asstText.trim()) {
for (let j = i - 1; j >= 0; j--) {
if (hist[j]?.role === 'user') {
userText = hist[j].content || '';
break;
}
}
break;
}
}
if (!userText) {
for (let i = hist.length - 1; i >= 0; i--) {
if (hist[i]?.role === 'user') {
userText = hist[i].content || '';
break;
}
}
}
const messages = [
{ role: 'user', content: userText },
{ role: 'assistant', content: asstText },
];
window.SA?.training?.curateFromChat?.(messages, {
chatId: state.activeChatId,
persona: $('sa_persona')?.value || 'neutral',
pack: $('sa_pack')?.value || defaultPackId(),
status,
})?.then?.((ok) => {
if (ok) {
setStatus(status === 'approved' ? 'Пример добавлен в датасет' : 'Пример отмечен как брак');
}
});
} }
function isTrainingLocked() { function isTrainingLocked() {
return !!state.trainingLock || document.getElementById('swarm_assistent_root')?.classList.contains('sa-root-training-lock'); return false;
} }
function wantsAutoVision() { function wantsAutoVision() {
@@ -6308,7 +6254,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
document.documentElement.style.setProperty('--sa-image-width', paneW); document.documentElement.style.setProperty('--sa-image-width', paneW);
} }
if (view === 'cards') { view = 'chat'; } if (view === 'cards') { view = 'chat'; }
if (view === 'chat' || view === 'settings' || view === 'train') { if (view === 'chat' || view === 'settings') {
state.view = view; state.view = view;
} }
const drawer = localStorage.getItem(LS_CHATS_DRAWER); const drawer = localStorage.getItem(LS_CHATS_DRAWER);
@@ -6375,7 +6321,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } }); fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v)); fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
if (ui.view === 'cards') { ui.view = 'chat'; } if (ui.view === 'cards') { ui.view = 'chat'; }
if (ui.view === 'chat' || ui.view === 'settings' || ui.view === 'train') { if (ui.view === 'chat' || ui.view === 'settings') {
fill(LS_VIEW, ui.view, (v) => { state.view = v; }); fill(LS_VIEW, ui.view, (v) => { state.view = v; });
} }
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') { if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
@@ -6457,9 +6403,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
? { ...state.config.control_values } ? { ...state.config.control_values }
: null; : null;
state.config = data; state.config = data;
if (window.SA?.training?.onConfig) {
window.SA.training.onConfig(data);
}
if (window.SA?.applyConfigPatchKeys) { if (window.SA?.applyConfigPatchKeys) {
window.SA.applyConfigPatchKeys(data); window.SA.applyConfigPatchKeys(data);
} }
@@ -7561,10 +7504,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
function loadPersonaPreview(id) { function loadPersonaPreview(id) {
const box = $('sa_persona_preview'); const box = $('sa_persona_preview');
const knowPanel = $('sa_knowledge_panel');
if (!box || typeof genericRequest !== 'function') { if (!box || typeof genericRequest !== 'function') {
return; return;
} }
box.innerHTML = '<div class="sa-mem-empty">Загрузка…</div>'; box.innerHTML = '<div class="sa-mem-empty">Загрузка…</div>';
if (knowPanel) {
knowPanel.hidden = true;
}
genericRequest( genericRequest(
'AssistentGetPersonaShelves', 'AssistentGetPersonaShelves',
{ persona: id }, { persona: id },
@@ -7573,6 +7520,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
const src = data?.source || ''; const src = data?.source || '';
box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`; box.textContent = `${id} · ${personaSourceLabel(src)}\n\n${summary || '(пусто)'}`;
syncPersonaPanelActions(); syncPersonaPanelActions();
loadPersonaKnowledge(id);
}, },
0, 0,
(err) => { (err) => {
@@ -7581,6 +7529,75 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
); );
} }
function loadPersonaKnowledge(personaId) {
const panel = $('sa_knowledge_panel');
const list = $('sa_knowledge_list');
const saveBtn = $('sa_btn_knowledge_save');
const hint = $('sa_knowledge_hint');
if (!panel || !list || typeof genericRequest !== 'function') {
return;
}
list.innerHTML = '<div class="sa-mem-empty">Загрузка книг…</div>';
panel.hidden = false;
genericRequest(
'AssistentListKnowledgeCatalog',
{ persona: personaId },
(data) => {
const cat = data?.knowledge || {};
const attach = new Set((cat.attach || []).map(String));
const books = cat.books || [];
const editable = !!data?.editable;
if (hint) {
hint.textContent = editable
? 'Отметь книги для FTS-поиска (ask:knowledge). Сохраняется в overlay knowledge.json.'
: 'Bundled/pack — attach задан по умолчанию или assistent-pack.yaml. Клонируй в overlay для правок.';
}
if (saveBtn) {
saveBtn.hidden = !editable;
}
list.innerHTML = '';
if (!books.length) {
list.innerHTML = '<div class="sa-mem-empty">Книги не найдены на диске. gpu-rent seed-books + up.</div>';
return;
}
for (const b of books) {
const row = document.createElement('label');
row.className = 'sa-check sa-knowledge-row';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.value = b.id;
cb.checked = attach.has(String(b.id));
cb.disabled = !editable;
row.appendChild(cb);
const title = b.title || b.id;
const kind = b.content_kind ? ` · ${b.content_kind}` : '';
const idx = b.indexed ? '' : ' · нет search.jsonl';
row.appendChild(document.createTextNode(` ${title} (${b.id})${kind}${idx}`));
list.appendChild(row);
}
if (saveBtn && editable) {
saveBtn.onclick = () => {
const picked = [...list.querySelectorAll('input[type=checkbox]:checked')].map((el) => el.value);
genericRequest(
'AssistentSaveKnowledgeAttach',
{ persona: personaId, attach: picked },
() => {
setStatus('Knowledge attach сохранён');
loadPersonaKnowledge(personaId);
},
0,
(err) => setStatus(String(err || 'save failed')),
);
};
}
},
0,
(err) => {
list.innerHTML = `<div class="sa-mem-empty">${escapeHtml(String(err || 'ошибка'))}</div>`;
},
);
}
function exportSelectedPersona() { function exportSelectedPersona() {
const id = state.settingsPersonaId || $('sa_persona')?.value; const id = state.settingsPersonaId || $('sa_persona')?.value;
if (!id || typeof genericRequest !== 'function') { if (!id || typeof genericRequest !== 'function') {
@@ -7927,23 +7944,17 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
} }
if (view === 'settings') { if (view === 'settings') {
state.view = 'settings'; state.view = 'settings';
} else if (view === 'train') {
state.view = 'train';
} else { } else {
state.view = 'chat'; state.view = 'chat';
} }
const chat = $('sa_view_chat'); const chat = $('sa_view_chat');
const settings = $('sa_view_settings'); const settings = $('sa_view_settings');
const train = $('sa_view_train');
if (chat) { if (chat) {
chat.hidden = state.view !== 'chat'; chat.hidden = state.view !== 'chat';
} }
if (settings) { if (settings) {
settings.hidden = state.view !== 'settings'; settings.hidden = state.view !== 'settings';
} }
if (train) {
train.hidden = state.view !== 'train';
}
const tabActive = (id, on) => { const tabActive = (id, on) => {
$(id)?.classList.toggle('sa-subtab-active', on); $(id)?.classList.toggle('sa-subtab-active', on);
$(id)?.classList.toggle('sa-app-tab-active', on); $(id)?.classList.toggle('sa-app-tab-active', on);
@@ -7951,12 +7962,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
}; };
tabActive('sa_tab_chat', state.view === 'chat'); tabActive('sa_tab_chat', state.view === 'chat');
tabActive('sa_tab_settings', state.view === 'settings'); tabActive('sa_tab_settings', state.view === 'settings');
tabActive('sa_tab_train', state.view === 'train');
saveSettings(); saveSettings();
if (state.view === 'settings') { if (state.view === 'settings') {
setSettingsTab(state.settingsTab || 'behavior'); setSettingsTab(state.settingsTab || 'behavior');
} else if (state.view === 'train') {
window.SA?.training?.render?.();
} else if ((state.llmParked || state.expectColdLoad) && !state.generating) { } else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
// Back in the chat — bring the model home (Krea may have evicted it). // Back in the chat — bring the model home (Krea may have evicted it).
warmLlm({ force: true }); warmLlm({ force: true });
@@ -9222,6 +9230,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
}); });
} }
} }
if (meta.knowledge?.hops?.length) {
const hopLabels = meta.knowledge.hops.map((h) => `${h.tool || 'hop'}:${h.count ?? '?'}`).slice(0, 6);
activityDone('knowledge', {
kind: 'ask',
label: 'knowledge hops',
detail: hopLabels.join(' · '),
});
}
captureActivityTrace(); captureActivityTrace();
state.history.push({ state.history.push({
role: 'assistant', role: 'assistant',
@@ -9339,6 +9355,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
system_chars: data.system_chars, system_chars: data.system_chars,
system_layers: data.system_layers, system_layers: data.system_layers,
prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count, prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count,
knowledge: data.knowledge,
}); });
} }
}, },
@@ -9371,6 +9388,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
system_chars: data.system_chars, system_chars: data.system_chars,
system_layers: data.system_layers, system_layers: data.system_layers,
prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count, prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count,
knowledge: data.knowledge,
}); });
}, },
0, 0,
@@ -9398,6 +9416,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
system_chars: data.system_chars, system_chars: data.system_chars,
system_layers: data.system_layers, system_layers: data.system_layers,
prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count, prompt_eval_count: data.prompt_eval_count ?? data.raw?.prompt_eval_count,
knowledge: data.knowledge,
}); });
}, },
0, 0,
@@ -9600,7 +9619,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
window.__swarmAssistentWired = true; window.__swarmAssistentWired = true;
loadSettings(); loadSettings();
setView(state.view || 'chat'); setView(state.view || 'chat');
void window.SA?.training?.resumePolling?.();
updateGate(); updateGate();
ensureBoard(); ensureBoard();
setBoardTab(state.boardTab || 'generate', { persist: false }); setBoardTab(state.boardTab || 'generate', { persist: false });
@@ -9699,7 +9717,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable }
$('sa_tab_chat')?.addEventListener('click', () => setView('chat')); $('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_train')?.addEventListener('click', () => setView('train'));
$('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior')); $('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate')); $('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs')); $('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
-3
View File
@@ -29,6 +29,3 @@ window.SA.applyConfigPatchKeys = function (config) {
}; };
import './app.js'; import './app.js';
import { attachTraining } from './training.js';
attachTraining(window.SA);
-942
View File
@@ -1,942 +0,0 @@
/** Swarm Assistent — training tab (dataset, Modelfile, QLoRA). */
const $ = (id) => document.getElementById(id);
function escapeHtml(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const QLORA_HF_CUSTOM = '__custom__';
export function attachTraining(SA) {
const state = {
ttab: 'dataset',
samples: [],
hfResults: [],
hfSelected: null,
hfCheck: null,
hfMapping: null,
trainWs: null,
polling: null,
qloraTraining: null,
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
agentLinked: 0,
};
function setAgentHeardStats(linked) {
const el = $('sa_agent_heard_stats');
if (el) {
el.textContent = `Подключено: ${linked ?? state.agentLinked ?? '—'}`;
}
}
async function loadAgentHeardSettings() {
try {
const data = await SA.request('AssistentGetDatasetAgentSettings', {});
const s = data?.settings || {};
state.agentSettings = {
enabled: s.enabled !== false,
auto_link_on_approve: s.auto_link_on_approve !== false,
heard_quota: s.heard_quota ?? 3,
};
state.agentLinked = data?.linked ?? 0;
if ($('sa_agent_heard_enabled')) $('sa_agent_heard_enabled').checked = state.agentSettings.enabled;
if ($('sa_agent_auto_link')) $('sa_agent_auto_link').checked = state.agentSettings.auto_link_on_approve;
if ($('sa_agent_heard_quota')) $('sa_agent_heard_quota').value = String(state.agentSettings.heard_quota);
setAgentHeardStats(state.agentLinked);
} catch (e) {
const msg = String(e.message || e);
setTrainStatus(formatTrainError(msg));
console.warn('loadAgentHeardSettings', e);
}
}
async function saveAgentHeardSettings() {
const settings = {
enabled: !!$('sa_agent_heard_enabled')?.checked,
auto_link_on_approve: !!$('sa_agent_auto_link')?.checked,
heard_quota: Math.max(0, Math.min(8, parseInt($('sa_agent_heard_quota')?.value, 10) || 3)),
};
try {
const data = await SA.request('AssistentSaveDatasetAgentSettings', { settings });
state.agentSettings = data?.settings || settings;
setTrainStatus('Настройки «услышанного» сохранены');
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function syncAllToAgent() {
setTrainStatus('Подключение к агенту…');
try {
await saveAgentHeardSettings();
const data = await SA.request('AssistentSyncDatasetToAgent', { approved_only: true, relink: false });
state.agentLinked = data?.total_linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus(`Подключено: +${data?.linked_now ?? 0}, всего ${data?.total_linked ?? '—'}`);
await refreshSamples();
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
function setTrainStatus(msg) {
const el = $('sa_train_status');
if (el) el.textContent = msg || '';
}
function isSqliteError(msg) {
const s = String(msg || '').toLowerCase();
return s.includes('sqlite')
|| s.includes('sqlconnection')
|| s.includes('train_samples')
|| s.includes('training database unavailable');
}
function isGenericServerError(msg) {
return /internal error occurred/i.test(String(msg || ''));
}
function formatTrainError(msg) {
const s = String(msg || '');
if (isSqliteError(s)) {
return `${s}${sqliteHint()}`;
}
if (isGenericServerError(s)) {
return `${s} — часто SQLite/импорт HF: gpu-rent seed-extensions, restart SwarmUI, HF token для gated.`;
}
return s;
}
function sqliteHint() {
return 'База Assistent (SQLite) недоступна — gpu-rent seed-extensions + restart SwarmUI (≥0.15.6).';
}
function parseJobProgress(job) {
if (!job) return null;
const raw = job.progress_json;
if (!raw) return null;
try {
return typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
return null;
}
}
function renderLastTrainJob(job) {
const box = $('sa_train_last_job');
if (!box) return;
if (!job) {
box.hidden = true;
box.innerHTML = '';
return;
}
const prog = parseJobProgress(job);
const status = job.status || prog?.status || '—';
const out = job.output_name || prog?.ollama?.name || '—';
const base = job.base_model || '—';
const ollama = prog?.ollama;
let ollamaLine = '';
if (ollama?.success) {
ollamaLine = `<div class="sa-train-last-ok">Ollama: <strong>${escapeHtml(ollama.name || out)}</strong> — выбери в шапке чата</div>`;
} else if (ollama?.error) {
ollamaLine = `<div class="sa-train-last-warn">Ollama: ${escapeHtml(ollama.error)}</div>`;
} else if (ollama?.skipped) {
ollamaLine = `<div class="sa-train-last-warn">${escapeHtml(ollama.note || 'Адаптер на диске, ollama create вручную')}</div>`;
}
box.hidden = false;
box.innerHTML = `
<div class="sa-train-last-head">Последняя тренировка · ${escapeHtml(job.kind || 'qlora')} · <span class="sa-hf-badge sa-hf-badge-${status === 'completed' ? 'ok' : status === 'failed' ? 'no' : 'map'}">${escapeHtml(status)}</span></div>
<div>HF base: <code>${escapeHtml(base)}</code> → имя: <code>${escapeHtml(out)}</code></div>
${ollamaLine}
${prog?.log ? `<pre class="sa-train-log sa-train-last-log">${escapeHtml(String(prog.log).slice(-4000))}</pre>` : ''}`;
}
function setTrainingTab(id) {
state.ttab = id || 'dataset';
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
const on = btn.getAttribute('data-ttab') === state.ttab;
btn.classList.toggle('sa-ttab-active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('#sa_training .sa-tpane').forEach((pane) => {
pane.hidden = pane.getAttribute('data-tpane') !== state.ttab;
});
if (state.ttab === 'dataset') {
refreshSamples();
loadAgentHeardSettings();
}
if (state.ttab === 'train') {
syncModelfileModels();
syncQloraModels();
void resumeTrainJobPolling();
}
if (state.ttab === 'models') refreshTrainModels();
}
async function refreshSamples() {
try {
const status = $('sa_train_filter_status')?.value || 'all';
const persona = $('sa_train_filter_persona')?.value || 'all';
const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 });
if (data?.error) {
const msg = data.error;
setTrainStatus(formatTrainError(msg));
const stats = $('sa_train_stats');
if (stats) stats.textContent = 'Датасет недоступен (SQLite)';
state.samples = [];
renderSamples();
return;
}
state.samples = data?.samples || [];
const stats = $('sa_train_stats');
if (stats) {
const appr = data?.approved ?? '—';
const draft = data?.draft ?? '—';
const total = data?.total ?? '—';
stats.textContent = `Одобрено: ${appr} · черновики: ${draft} · всего: ${total}`;
}
const personaSel = $('sa_train_filter_persona');
if (personaSel && $('sa_persona')) {
const cur = personaSel.value || 'all';
personaSel.innerHTML = '<option value="all">Все личности</option>';
for (const opt of $('sa_persona').options) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.textContent;
personaSel.appendChild(o);
}
personaSel.value = cur;
}
renderSamples();
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
function renderSamples() {
const root = $('sa_train_samples');
if (!root) return;
const filter = $('sa_train_filter_status')?.value || 'all';
if (!state.samples.length) {
const hint = filter === 'approved'
? 'Под фильтром «Одобренные» пусто. HF-импорт создаёт <strong>черновики</strong> — переключи на «Черновики» или «Все статусы».'
: filter === 'draft'
? 'Нет черновиков. Импортируй HF или отметь примеры в чате.'
: 'Нет примеров. Отметь ответы в чате или импортируй датасет (HF / файл).';
root.innerHTML = `<div class="sa-mem-empty">${hint}</div>`;
return;
}
root.innerHTML = '';
for (const s of state.samples) {
const div = document.createElement('div');
div.className = 'sa-train-sample';
div.dataset.id = s.id;
const msgs = s.messages || [];
const preview = msgs.map((m) => `${m.role}: ${(m.content || '').slice(0, 120)}`).join('\n');
const linked = s.agent_linked ? ' · 🔗 агент' : '';
div.innerHTML = `
<div class="sa-train-sample-head">
<span class="sa-hf-badge sa-hf-badge-${s.status === 'approved' ? 'ok' : s.status === 'rejected' ? 'no' : 'map'}">${escapeHtml(s.status)}</span>
<span>${escapeHtml(s.source)} · ${escapeHtml(s.persona || '—')} · ${escapeHtml(s.pack || '—')}${linked}</span>
<button type="button" class="basic-button" data-approve="1">✓</button>
<button type="button" class="basic-button" data-reject="1">✕</button>
<button type="button" class="basic-button" data-link="1" title="Подключить к агенту">🔗</button>
<button type="button" class="basic-button" data-unlink="1" title="Отключить от агента">⛓</button>
<button type="button" class="basic-button sa-danger-btn" data-del="1">Удалить</button>
</div>
<textarea spellcheck="false">${escapeHtml(preview)}</textarea>`;
root.appendChild(div);
}
}
async function upsertSample(patch) {
await SA.request('AssistentUpsertTrainSample', patch);
await refreshSamples();
}
function hfStringColumns(check) {
const cols = check?.schema?.columns;
if (Array.isArray(cols) && cols.length) return cols;
const feats = check?.features;
if (Array.isArray(feats)) {
return feats.map((f) => f?.name).filter(Boolean);
}
if (feats && typeof feats === 'object') return Object.keys(feats);
return [];
}
function renderHfMappingUI(check) {
const row = $('sa_hf_mapping_row');
if (!row) return;
const gate = check?.gate;
const schemaKind = check?.schema?.kind;
const needsMapping = gate === 'mapping' || schemaKind === 'fiction_tags_text';
row.hidden = !needsMapping;
if (!needsMapping) {
state.hfMapping = null;
return;
}
const cols = hfStringColumns(check);
const userSel = $('sa_hf_user_col');
const asstSel = $('sa_hf_asst_col');
const presetSel = $('sa_hf_mapping_preset');
if (userSel) {
userSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
if (cols.includes('tags')) userSel.value = 'tags';
else if (cols.includes('title')) userSel.value = 'title';
}
if (asstSel) {
asstSel.innerHTML = cols.map((c) => `<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`).join('');
if (cols.includes('text')) asstSel.value = 'text';
else if (cols.includes('output')) asstSel.value = 'output';
}
if (schemaKind === 'fiction_tags_text' && presetSel) {
presetSel.value = 'fiction_tags_text';
state.hfMapping = { kind: 'fiction_tags_text', preset: 'fiction_tags_text' };
}
}
function buildHfMappingPayload() {
const preset = $('sa_hf_mapping_preset')?.value;
if (preset === 'fiction_tags_text') {
return { kind: 'fiction_tags_text', preset: 'fiction_tags_text' };
}
const userCol = $('sa_hf_user_col')?.value;
const asstCol = $('sa_hf_asst_col')?.value;
if (userCol && asstCol) {
return { kind: 'custom', user_col: userCol, assistant_col: asstCol };
}
return state.hfMapping;
}
function getQloraHfBase() {
const sel = $('sa_qlora_base');
if (!sel) return '';
if (sel.value === QLORA_HF_CUSTOM) {
return ($('sa_qlora_base_custom')?.value || '').trim();
}
return (sel.value || '').trim();
}
function applyQloraPresetFromSelect({ fillName = true } = {}) {
const sel = $('sa_qlora_base');
const customRow = $('sa_qlora_base_custom_row');
if (!sel) return;
if (sel.value === QLORA_HF_CUSTOM) {
if (customRow) customRow.hidden = false;
return;
}
if (customRow) customRow.hidden = true;
const presetJson = sel.selectedOptions[0]?.dataset?.preset;
if (!presetJson) return;
let preset;
try {
preset = JSON.parse(presetJson);
} catch {
return;
}
const nameEl = $('sa_qlora_name');
if (fillName && nameEl && !nameEl.value.trim() && preset.default_output) {
nameEl.value = preset.default_output;
}
const ollamaSel = $('sa_qlora_ollama_base');
if (ollamaSel && preset.ollama_hint) {
const hint = preset.ollama_hint;
if ([...ollamaSel.options].some((o) => o.value === hint)) {
ollamaSel.value = hint;
}
}
if (preset.rank != null && $('sa_qlora_rank')) {
$('sa_qlora_rank').value = preset.rank;
}
if (preset.seq_len != null && $('sa_qlora_seq')) {
$('sa_qlora_seq').value = preset.seq_len;
}
}
function populateQloraHfPresets(training) {
const sel = $('sa_qlora_base');
if (!sel) return;
const prevBase = getQloraHfBase();
const models = Array.isArray(training?.hf_models) ? training.hf_models : [];
sel.innerHTML = '<option value="">— выберите модель —</option>';
for (const m of models) {
const hfId = (m.hf_id || m.id || '').trim();
if (!hfId) continue;
const opt = document.createElement('option');
opt.value = hfId;
opt.textContent = m.title ? `${m.title} (${hfId})` : hfId;
opt.dataset.preset = JSON.stringify(m);
sel.appendChild(opt);
}
const customOpt = document.createElement('option');
customOpt.value = QLORA_HF_CUSTOM;
customOpt.textContent = 'Другая (ввести HF id…)';
sel.appendChild(customOpt);
if (prevBase && [...sel.options].some((o) => o.value === prevBase)) {
sel.value = prevBase;
} else if (prevBase) {
sel.value = QLORA_HF_CUSTOM;
const custom = $('sa_qlora_base_custom');
if (custom) custom.value = prevBase;
} else if (models.length) {
sel.value = (models[0].hf_id || models[0].id || '').trim();
}
applyQloraPresetFromSelect({ fillName: !prevBase });
}
async function ensureTrainingQloraConfig(force = false) {
if (!force && state.qloraTraining) {
return state.qloraTraining;
}
try {
const persona = $('sa_persona')?.value || '';
const data = await SA.request('AssistentGetConfig', { persona });
state.qloraTraining = data?.training && typeof data.training === 'object'
? data.training
: { hf_models: [] };
return state.qloraTraining;
} catch {
state.qloraTraining = state.qloraTraining || { hf_models: [] };
return state.qloraTraining;
}
}
async function syncQloraModels() {
try {
const training = await ensureTrainingQloraConfig();
populateQloraHfPresets(training);
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
const data = await SA.request('AssistentListModels', { baseUrl });
const models = data?.models || [];
const sel = $('sa_qlora_ollama_base');
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '<option value="">—</option>';
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
sel.appendChild(opt);
}
if (cur) sel.value = cur;
else if ($('sa_model')?.value) sel.value = $('sa_model').value;
applyQloraPresetFromSelect({ fillName: false });
} catch (e) { /* ignore */ }
}
function renderHfList() {
const root = $('sa_hf_list');
if (!root) return;
root.innerHTML = '';
const showAll = !!$('sa_hf_show_all')?.checked;
for (const r of state.hfResults) {
if (!showAll && r.gate === 'rejected') continue;
const row = document.createElement('div');
row.className = 'sa-hf-row' + (state.hfSelected === r.id ? ' sa-hf-row-active' : '') + (r.gate === 'rejected' ? ' sa-hf-rejected' : '');
row.dataset.id = r.id;
const badge = r.gate === 'ok' ? 'ok' : r.gate === 'mapping' ? 'map' : 'no';
row.innerHTML = `<span class="sa-hf-badge sa-hf-badge-${badge}">${escapeHtml(r.gate)}</span><strong>${escapeHtml(r.id)}</strong><span>${escapeHtml(r.reason || '')}</span>`;
root.appendChild(row);
}
}
async function searchHf() {
const q = ($('sa_hf_search')?.value || '').trim();
setTrainStatus('Поиск…');
try {
const data = await SA.request('AssistentSearchHfDatasets', {
q,
limit: 24,
show_all: !!$('sa_hf_show_all')?.checked,
});
state.hfResults = data?.results || [];
renderHfList();
setTrainStatus(`Найдено: ${state.hfResults.length}`);
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function checkHfLink() {
const link = ($('sa_hf_link')?.value || '').trim();
const status = $('sa_hf_status');
if (!link) return;
if (status) status.textContent = 'Проверяю…';
try {
const data = await SA.request('AssistentCheckHfDataset', { dataset: link });
state.hfCheck = data;
state.hfSelected = data.id;
if (status) {
status.textContent = data.gate === 'rejected'
? `Отклонено: ${data.reason}`
: `${data.gate}: ${data.reason || 'OK'}`;
}
const preview = $('sa_hf_preview');
if (preview) {
preview.hidden = false;
preview.textContent = JSON.stringify(data.sample_rows || data.features || data, null, 2).slice(0, 8000);
}
const importRow = $('sa_hf_import_row');
if (importRow) importRow.hidden = data.gate === 'rejected';
renderHfMappingUI(data);
} catch (e) {
if (status) status.textContent = String(e.message || e);
}
}
async function importHf() {
const link = ($('sa_hf_link')?.value || '').trim();
if (!state.hfCheck?.id && link) {
await checkHfLink();
}
if (!state.hfSelected && !state.hfCheck?.id) {
setTrainStatus('Сначала проверь набор');
const hfSt = $('sa_hf_status');
if (hfSt) hfSt.textContent = 'Вставь ссылку и нажми «Проверить»';
return;
}
const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200;
const mapping = buildHfMappingPayload();
const btn = $('sa_btn_hf_import');
const hfSt = $('sa_hf_status');
const busy = limit > 400
? `Импортирую до ${limit} строк… (12 мин)`
: `Импортирую до ${limit}`;
setTrainStatus(busy);
if (hfSt) {
hfSt.textContent = busy;
hfSt.classList.add('sa-hf-busy');
}
if (btn) {
btn.disabled = true;
btn.dataset.label = btn.textContent;
btn.textContent = '…';
}
try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit, mapping });
const n = Number(data?.imported) || 0;
let msg;
if (data?.runner_only) {
msg = `Runner-only: ${data.note || id} (в sqlite не импортировали)`;
} else if (n > 0) {
const filt = $('sa_train_filter_status');
if (filt && filt.value === 'approved') {
filt.value = 'draft';
}
msg = `Импортировано: ${n} черновик(ов) — список ниже (фильтр → черновики)`;
} else {
msg = 'Импортировано: 0 — HF token в User Settings, маппинг или gated-набор';
}
setTrainStatus(msg);
if (hfSt) hfSt.textContent = msg;
await refreshSamples();
$('sa_train_samples')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
const err = String(e.message || e);
const show = formatTrainError(err);
setTrainStatus(show);
if (hfSt) hfSt.textContent = show;
} finally {
if (hfSt) hfSt.classList.remove('sa-hf-busy');
if (btn) {
btn.disabled = false;
btn.textContent = btn.dataset.label || 'Импортировать';
}
}
}
async function syncModelfileModels() {
try {
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
const data = await SA.request('AssistentListModels', { baseUrl });
const models = data?.models || [];
for (const selId of ['sa_modelfile_base']) {
const sel = $(selId);
if (!sel) continue;
const cur = sel.value;
sel.innerHTML = '<option value="">—</option>';
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
sel.appendChild(opt);
}
if (cur) sel.value = cur;
}
const personaSel = $('sa_modelfile_persona');
if (personaSel && $('sa_persona')) {
personaSel.innerHTML = $('sa_persona').innerHTML;
personaSel.value = $('sa_persona').value || 'neutral';
}
} catch (e) { /* ignore */ }
}
async function createModelfile() {
const btn = $('sa_btn_modelfile_create');
btn?.setAttribute('disabled', 'disabled');
setTrainStatus('Создаю Modelfile в Ollama…');
try {
const data = await SA.request('AssistentCreateOllamaModel', {
base_url: $('sa_base_url')?.value,
base_model: $('sa_modelfile_base')?.value,
name: $('sa_modelfile_name')?.value,
persona: $('sa_modelfile_persona')?.value,
system: $('sa_modelfile_system')?.value,
shots: Number($('sa_modelfile_shots')?.value) || 8,
num_ctx: Number($('sa_modelfile_num_ctx')?.value) || 16384,
temperature: Number($('sa_modelfile_temp')?.value) || 0.7,
});
setTrainStatus(`Готово: ${data.name}`);
SA.app?.refreshModels?.();
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
} finally {
btn?.removeAttribute('disabled');
}
}
function setTrainMode(mode) {
$('sa_train_form_modelfile').hidden = mode !== 'modelfile';
$('sa_train_form_qlora').hidden = mode !== 'qlora';
const radio = document.querySelector(`input[name="sa_train_mode"][value="${mode}"]`);
if (radio) radio.checked = true;
}
function setTrainingLock(on, text) {
const root = $('swarm_assistent_root');
const banner = $('sa_train_banner');
if (root) root.classList.toggle('sa-root-training-lock', !!on);
if (banner) {
banner.hidden = !on;
const t = $('sa_train_banner_text');
if (t && text) t.textContent = text;
}
SA.app?.setTrainingLock?.(!!on);
}
async function pollTrainJob() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const prog = data?.progress || (data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null);
const active = data?.training_active || data?.job?.status === 'running';
const status = data?.job?.status || prog?.status;
const pct = prog?.percent;
const bannerText = active && pct != null
? `QLoRA · ${pct}%`
: active
? 'Идёт QLoRA…'
: 'Идёт тренировка…';
setTrainingLock(active, bannerText);
const logEl = $('sa_train_log');
const bar = $('sa_train_progress_fill');
const box = $('sa_train_progress');
if (prog) {
if (box) box.hidden = false;
if (bar && pct != null) bar.style.width = `${pct}%`;
if (logEl && prog.log) logEl.textContent = prog.log;
}
if (active) {
setTrainStatus(pct != null
? `QLoRA · ${pct}% — полный лог на вкладке «Тренировка»`
: 'QLoRA запущена — полный лог на вкладке «Тренировка»');
}
if (!active) {
clearInterval(state.polling);
state.polling = null;
$('sa_btn_qlora_cancel').hidden = true;
setTrainingLock(false);
if (status === 'completed' || status === 'completed_with_warnings') {
const ollama = prog?.ollama;
if (ollama?.success) {
setTrainStatus(`Готово: модель ${ollama.name} в Ollama — вкладка «Модели»`);
SA.app?.refreshModels?.();
} else if (ollama?.skipped) {
setTrainStatus(ollama.note || ollama.error || 'Адаптер сохранён — см. вкладку «Модели»');
} else if (ollama?.error) {
setTrainStatus(`Обучение OK, Ollama: ${ollama.error}`);
} else if (status === 'completed_with_warnings') {
setTrainStatus('Завершено с предупреждениями — лог на «Модели»');
} else {
setTrainStatus('QLoRA завершено — вкладка «Модели»');
}
setTrainingTab('models');
await refreshTrainModels();
} else if (status === 'failed') {
setTrainStatus(`Ошибка тренировки (exit ${prog?.exit_code ?? '?'})`);
}
}
} catch (e) { /* ignore */ }
}
async function resumeTrainJobPolling() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const active = data?.training_active || data?.job?.status === 'running';
await pollTrainJob();
if (!active) return;
setTrainMode('qlora');
$('sa_btn_qlora_cancel').hidden = false;
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
} catch (e) { /* ignore */ }
}
async function startQlora() {
const baseModel = getQloraHfBase();
const outputName = ($('sa_qlora_name')?.value || '').trim();
if (!baseModel) {
setTrainStatus('Выберите HF base model из списка или укажите свой HF id');
$('sa_qlora_base')?.focus();
return;
}
if (!outputName) {
setTrainStatus('Укажите имя модели в Ollama (например my-lora:v1)');
$('sa_qlora_name')?.focus();
return;
}
setTrainStatus('Запуск…');
try {
const hfDs = ($('sa_qlora_hf_dataset')?.value || '').trim();
const mapping = hfDs ? buildHfMappingPayload() : undefined;
await SA.request('AssistentStartTrainJob', {
base_url: $('sa_base_url')?.value,
chat_model: $('sa_model')?.value,
base_model: baseModel,
ollama_base: $('sa_qlora_ollama_base')?.value,
output_name: outputName,
rank: Number($('sa_qlora_rank')?.value) || 16,
alpha: Number($('sa_qlora_alpha')?.value) || 32,
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
epochs: Number($('sa_qlora_epochs')?.value) || 3,
seq_len: Number($('sa_qlora_seq')?.value) || 2048,
max_samples: Number($('sa_qlora_max_samples')?.value) || 0,
four_bit: !!$('sa_qlora_4bit')?.checked,
hf_dataset: hfDs || undefined,
hf_mapping: mapping,
});
$('sa_btn_qlora_cancel').hidden = false;
setTrainingLock(true, 'Идёт тренировка…');
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
pollTrainJob();
setTrainMode('qlora');
setTrainingTab('train');
setTrainStatus('QLoRA запущена — прогресс ниже');
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function cancelQlora() {
try {
await SA.request('AssistentCancelTrainJob', {});
setTrainingLock(false);
setTrainStatus('Отменено');
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function refreshTrainModels() {
const root = $('sa_train_models_list');
if (!root) return;
try {
const jobData = await SA.request('AssistentGetTrainJob', {});
renderLastTrainJob(jobData?.last_job || jobData?.job);
const data = await SA.request('AssistentListModels', { baseUrl: $('sa_base_url')?.value });
const models = data?.models || [];
const lastOut = jobData?.last_job?.output_name;
root.innerHTML = models.length
? models.map((m) => {
const hit = lastOut && String(m).includes(String(lastOut).split(':')[0]);
return `<div class="sa-hf-row${hit ? ' sa-train-model-new' : ''}"><strong>${escapeHtml(m)}</strong>${hit ? ' · последняя тренировка' : ''}</div>`;
}).join('')
: '<div class="sa-mem-empty">Нет моделей в Ollama — после QLoRA нажми «Обновить» или проверь лог тренировки</div>';
} catch (e) {
const msg = String(e.message || e);
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(isSqliteError(msg) ? sqliteHint() : msg)}</div>`;
}
}
async function saveRunner() {
try {
await SA.request('AssistentSaveRunnerSettings', {
python: $('sa_runner_python')?.value,
kind: $('sa_runner_kind')?.value || 'builtin',
workdir: $('sa_runner_workdir')?.value,
cmd: $('sa_runner_cmd')?.value,
gguf_script: $('sa_runner_gguf_script')?.value,
gguf_base_path: $('sa_runner_gguf_base')?.value,
gguf_cmd: $('sa_runner_gguf_cmd')?.value,
});
setTrainStatus('Раннер сохранён');
} catch (e) {
setTrainStatus(formatTrainError(e.message || e));
}
}
async function loadRunner() {
try {
const data = await SA.request('AssistentGetRunnerSettings', {});
const s = data?.settings || {};
if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python;
if ($('sa_runner_kind')) $('sa_runner_kind').value = s.kind || 'builtin';
if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir;
if ($('sa_runner_cmd') && s.cmd) $('sa_runner_cmd').value = s.cmd;
if ($('sa_runner_gguf_script') && s.gguf_script) $('sa_runner_gguf_script').value = s.gguf_script;
if ($('sa_runner_gguf_base') && s.gguf_base_path) $('sa_runner_gguf_base').value = s.gguf_base_path;
if ($('sa_runner_gguf_cmd') && s.gguf_cmd) $('sa_runner_gguf_cmd').value = s.gguf_cmd;
} catch (e) { /* ignore */ }
}
function wireTraining() {
if (window.__saTrainingWired) return;
window.__saTrainingWired = true;
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
btn.addEventListener('click', () => setTrainingTab(btn.getAttribute('data-ttab')));
});
$('sa_btn_agent_sync')?.addEventListener('click', syncAllToAgent);
$('sa_agent_heard_enabled')?.addEventListener('change', saveAgentHeardSettings);
$('sa_agent_auto_link')?.addEventListener('change', saveAgentHeardSettings);
$('sa_agent_heard_quota')?.addEventListener('change', saveAgentHeardSettings);
loadAgentHeardSettings();
$('sa_btn_train_from_chats')?.addEventListener('click', async () => {
try {
const data = await SA.request('AssistentBuildDatasetFromChats', {});
setTrainStatus(`Из чатов: +${data.added}`);
await refreshSamples();
} catch (e) { setTrainStatus(String(e.message || e)); }
});
$('sa_btn_train_import_file')?.addEventListener('click', () => $('sa_train_import_file')?.click());
$('sa_train_import_file')?.addEventListener('change', async (e) => {
const file = e.target?.files?.[0];
if (!file) return;
const text = await file.text();
try {
const data = await SA.request('AssistentImportDataset', { format: 'auto', content: text });
setTrainStatus(`Импорт: ${data.imported}`);
await refreshSamples();
} catch (err) { setTrainStatus(formatTrainError(err.message || err)); }
e.target.value = '';
});
$('sa_btn_train_export')?.addEventListener('click', async () => {
try {
const data = await SA.request('AssistentExportDataset', { status: 'approved' });
if (data.content) {
const blob = new Blob([data.content], { type: 'application/jsonl' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'assistent-dataset.jsonl';
a.click();
}
setTrainStatus(`Экспорт: ${data.count} примеров`);
} catch (e) { setTrainStatus(String(e.message || e)); }
});
$('sa_train_filter_status')?.addEventListener('change', refreshSamples);
$('sa_train_filter_persona')?.addEventListener('change', refreshSamples);
$('sa_train_samples')?.addEventListener('click', async (e) => {
const row = e.target.closest('.sa-train-sample');
if (!row) return;
const id = row.dataset.id;
const sample = state.samples.find((s) => s.id === id);
if (!sample) return;
if (e.target.closest('[data-approve]')) {
await upsertSample({ ...sample, status: 'approved' });
await loadAgentHeardSettings();
} else if (e.target.closest('[data-reject]')) {
await upsertSample({ ...sample, status: 'rejected' });
await loadAgentHeardSettings();
} else if (e.target.closest('[data-link]')) {
try {
const data = await SA.request('AssistentLinkTrainSampleToAgent', { id });
state.agentLinked = data?.linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus('Пример подключён к агенту');
await refreshSamples();
} catch (err) { setTrainStatus(formatTrainError(err.message || err)); }
} else if (e.target.closest('[data-unlink]')) {
try {
const data = await SA.request('AssistentUnlinkTrainSampleFromAgent', { id });
state.agentLinked = data?.linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus('Пример отключён от агента');
await refreshSamples();
} catch (err) { setTrainStatus(formatTrainError(err.message || err)); }
} else if (e.target.closest('[data-del]')) {
if (window.confirm('Удалить пример?')) {
await SA.request('AssistentDeleteTrainSample', { id });
await refreshSamples();
}
}
});
$('sa_btn_hf_search')?.addEventListener('click', searchHf);
$('sa_hf_show_all')?.addEventListener('change', () => { renderHfList(); });
$('sa_hf_list')?.addEventListener('click', async (e) => {
const row = e.target.closest('.sa-hf-row');
if (!row || row.classList.contains('sa-hf-rejected')) return;
state.hfSelected = row.dataset.id;
$('sa_hf_link').value = row.dataset.id;
renderHfList();
await checkHfLink();
});
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
$('sa_hf_mapping_preset')?.addEventListener('change', () => {
state.hfMapping = buildHfMappingPayload();
});
$('sa_btn_hf_import')?.addEventListener('click', importHf);
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
r.addEventListener('change', () => setTrainMode(r.value));
});
$('sa_btn_modelfile_create')?.addEventListener('click', createModelfile);
$('sa_qlora_base')?.addEventListener('change', () => applyQloraPresetFromSelect());
$('sa_btn_qlora_start')?.addEventListener('click', startQlora);
$('sa_btn_qlora_cancel')?.addEventListener('click', cancelQlora);
$('sa_btn_train_models_refresh')?.addEventListener('click', refreshTrainModels);
$('sa_btn_save_runner')?.addEventListener('click', saveRunner);
loadRunner();
setTrainMode('modelfile');
void resumeTrainJobPolling();
}
SA.training = {
render() {
wireTraining();
setTrainingTab(state.ttab);
},
onConfig(data) {
if (data?.training && typeof data.training === 'object') {
state.qloraTraining = data.training;
if (state.ttab === 'train') {
populateQloraHfPresets(state.qloraTraining);
applyQloraPresetFromSelect({ fillName: false });
}
}
},
resumePolling: resumeTrainJobPolling,
async curateFromChat(messages, meta) {
try {
await SA.request('AssistentUpsertTrainSample', {
source: 'chat',
chat_id: meta?.chatId,
persona: meta?.persona,
pack: meta?.pack,
status: meta?.status || 'approved',
messages,
});
return true;
} catch (e) {
console.warn('curateFromChat', e);
return false;
}
},
setTrainingLock,
pollTrainJob,
};
}