Ship Assistent 0.12.1: training tab, dataset pipeline, and heard RAG.

Restructure UI with app-level tabs and chat history drawer; add dataset curation,
HF import, Modelfile/QLoRA hooks, and link approved samples to the agent immediately
via heard vector memory without waiting for fine-tuning.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 14:27:59 +03:00
co-authored by Cursor
parent e8bb012885
commit 1a03c3178f
21 changed files with 4919 additions and 638 deletions
+1060 -443
View File
File diff suppressed because it is too large Load Diff
+467 -18
View File
@@ -17,6 +17,137 @@
opacity: 0.95;
}
.sa-appbar {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
padding: 0.35rem 0.5rem 0.55rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
flex: 0 0 auto;
}
.sa-appbar-brand {
display: flex;
align-items: center;
gap: 0.45rem;
min-width: 0;
}
.sa-appbar-title {
font-weight: 700;
letter-spacing: 0.04em;
font-size: 0.92rem;
}
.sa-app-tabs {
display: inline-flex;
gap: 0.25rem;
flex: 1;
min-width: 0;
flex-wrap: wrap;
}
.sa-app-tab {
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: transparent;
color: inherit;
border-radius: 999px;
padding: 0.22rem 0.75rem;
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
opacity: 0.72;
}
.sa-app-tab-active {
opacity: 1;
background: color-mix(in srgb, currentColor 14%, transparent);
border-color: color-mix(in srgb, currentColor 42%, transparent);
}
.sa-train-banner {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.25rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid color-mix(in srgb, #f5a623 55%, currentColor);
background: color-mix(in srgb, #f5a623 16%, transparent);
flex: 0 0 auto;
}
.sa-views {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.sa-views > .sa-view {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.sa-views > .sa-view[hidden] {
display: none !important;
}
#sa_view_chat .sa-layout {
flex: 1;
min-height: 0;
}
.sa-chat-workspace {
flex: 1 1 auto;
display: flex;
min-width: 0;
min-height: 0;
gap: 0;
}
.sa-chat-body {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.sa-chats-drawer {
flex: 0 0 var(--sa-chats-drawer-width, 16rem);
width: var(--sa-chats-drawer-width, 16rem);
display: flex;
flex-direction: column;
gap: 0.35rem;
min-height: 0;
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 4%, transparent);
padding: 0.45rem 0.5rem;
overflow: hidden;
transition: width 0.2s ease, flex-basis 0.2s ease, opacity 0.2s ease;
}
.sa-chats-drawer[hidden] {
display: none !important;
}
.sa-chats-drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
font-size: 0.82rem;
}
.sa-chats-drawer-actions {
display: inline-flex;
gap: 0.2rem;
}
.sa-layout {
display: flex;
flex: 1;
@@ -411,22 +542,21 @@
background: color-mix(in srgb, currentColor 10%, transparent);
}
.sa-chats-panel {
position: absolute;
z-index: 50;
top: calc(100% - 1px);
left: 0.5rem;
right: 0.5rem;
max-width: 26rem;
max-height: min(50vh, 22rem);
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.55rem;
border-radius: 0 0 0.5rem 0.5rem;
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
background: color-mix(in srgb, #161616 94%, currentColor);
box-shadow: 0 12px 28px color-mix(in srgb, #000 40%, transparent);
.sa-chats-panel,
.sa-chats-drawer {
/* drawer lives in .sa-chat-workspace — not a dropdown */
position: static;
z-index: auto;
top: auto;
left: auto;
right: auto;
max-width: none;
max-height: none;
box-shadow: none;
border-radius: 0;
border: none;
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 4%, transparent);
}
.sa-chats-panel-head {
@@ -1491,7 +1621,13 @@
vertical-align: middle;
}
.sa-subtab {
/* app-level tabs replace in-pane subtabs */
.sa-subtabs {
display: none;
}
.sa-subtab,
.sa-app-tab {
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: transparent;
color: inherit;
@@ -1503,7 +1639,8 @@
opacity: 0.7;
}
.sa-subtab-active {
.sa-subtab-active,
.sa-app-tab-active {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
}
@@ -2016,4 +2153,316 @@
width: 98%;
max-height: 96%;
}
.sa-chat-workspace {
position: relative;
}
.sa-chats-drawer:not([hidden]) {
position: absolute;
z-index: 60;
top: 0;
right: 0;
bottom: 0;
width: min(18rem, 92vw);
flex: none;
box-shadow: -8px 0 24px color-mix(in srgb, #000 35%, transparent);
}
}
/* Training tab */
.sa-training {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
border-radius: 0.55rem;
background: color-mix(in srgb, currentColor 3%, transparent);
overflow: hidden;
}
.sa-training-tabs {
display: flex;
gap: 0.25rem;
padding: 0.45rem 0.55rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 16%, transparent);
flex-wrap: wrap;
}
.sa-ttab {
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: transparent;
color: inherit;
border-radius: 999px;
padding: 0.15rem 0.65rem;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
opacity: 0.72;
}
.sa-ttab-active {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.sa-training-panes {
flex: 1;
overflow: auto;
min-height: 0;
padding: 0.55rem 0.65rem 0.75rem;
}
.sa-tpane {
display: flex;
flex-direction: column;
gap: 0.55rem;
min-height: 0;
}
.sa-train-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.sa-train-stats {
font-size: 0.78rem;
font-weight: 600;
opacity: 0.85;
margin-right: 0.25rem;
}
.sa-agent-heard-panel {
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.45rem;
padding: 0.55rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.35rem;
}
.sa-agent-heard-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.65rem;
}
.sa-agent-heard-stats {
font-size: 0.78rem;
font-weight: 600;
opacity: 0.8;
}
.sa-agent-heard-hint {
margin: 0;
font-size: 0.78rem;
opacity: 0.78;
line-height: 1.35;
}
.sa-agent-heard-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 0.75rem;
}
.sa-agent-heard-controls label {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.82rem;
}
.sa-agent-heard-controls input[type="number"] {
width: 2.75rem;
}
.sa-hf-panel {
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.45rem;
padding: 0.55rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.sa-hf-search-row,
.sa-hf-link-row,
.sa-hf-import-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.sa-hf-search,
.sa-hf-link {
flex: 1 1 12rem;
min-width: 8rem;
}
.sa-hf-status {
font-size: 0.76rem;
opacity: 0.85;
min-height: 1.1rem;
}
.sa-hf-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 12rem;
overflow: auto;
}
.sa-hf-row {
display: flex;
align-items: flex-start;
gap: 0.35rem;
padding: 0.35rem 0.45rem;
border-radius: 0.35rem;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
cursor: pointer;
font-size: 0.76rem;
}
.sa-hf-row:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
.sa-hf-row.sa-hf-row-active {
border-color: color-mix(in srgb, currentColor 42%, transparent);
background: color-mix(in srgb, currentColor 12%, transparent);
}
.sa-hf-row.sa-hf-rejected {
opacity: 0.55;
cursor: not-allowed;
}
.sa-hf-badge {
font-size: 0.68rem;
padding: 0.05rem 0.35rem;
border-radius: 999px;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
white-space: nowrap;
}
.sa-hf-badge-ok {
border-color: color-mix(in srgb, #4caf50 50%, currentColor);
}
.sa-hf-badge-map {
border-color: color-mix(in srgb, #f5a623 50%, currentColor);
}
.sa-hf-badge-no {
border-color: color-mix(in srgb, #e74c3c 50%, currentColor);
}
.sa-hf-preview {
font-size: 0.72rem;
max-height: 10rem;
overflow: auto;
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
border-radius: 0.35rem;
padding: 0.4rem;
white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
.sa-train-samples {
display: flex;
flex-direction: column;
gap: 0.35rem;
max-height: 24rem;
overflow: auto;
}
.sa-train-sample {
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
border-radius: 0.4rem;
padding: 0.45rem 0.55rem;
font-size: 0.76rem;
}
.sa-train-sample-head {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.35rem;
}
.sa-train-sample textarea {
width: 100%;
box-sizing: border-box;
min-height: 3rem;
font-size: 0.74rem;
}
.sa-msg-curate {
display: inline-flex;
gap: 0.25rem;
margin-top: 0.35rem;
}
.sa-msg-curate button {
font-size: 0.72rem !important;
padding: 0.1rem 0.4rem !important;
}
.sa-train-modes {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sa-train-form label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.78rem;
margin-bottom: 0.35rem;
}
.sa-train-progress-bar {
height: 0.35rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
overflow: hidden;
}
.sa-train-progress-fill {
height: 100%;
width: 0%;
background: color-mix(in srgb, #4caf50 70%, currentColor);
transition: width 0.3s ease;
}
.sa-train-log {
max-height: 14rem;
overflow: auto;
font-size: 0.72rem;
margin: 0.35rem 0 0;
padding: 0.45rem;
border-radius: 0.35rem;
background: color-mix(in srgb, #000 22%, transparent);
}
.sa-train-models-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sa-root-training-lock .sa-composer,
.sa-root-training-lock #sa_btn_send,
.sa-root-training-lock #sa_btn_build_gen {
pointer-events: none;
opacity: 0.45;
}
+89 -8
View File
@@ -185,6 +185,7 @@ public partial class SwarmAssistentExtension
{
AssistentMemory.RetrieveOptions opt = MemoryRetrieveOptions(pid);
hits = await Memory.RetrieveAsync(root, retrieveQuery, opt.TopK, embed, Config.PersonaExtendsChain(pid), opt);
hits = FilterHeardHitsIfDisabled(hits);
}
catch (Exception ex)
{
@@ -192,7 +193,7 @@ public partial class SwarmAssistentExtension
}
}
string enrichedContext = InjectMemoryHits(contextJson, hits);
string enrichedContext = InjectMemoryHits(contextJson, hits, pid);
if (!slimDebug)
{
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
@@ -338,24 +339,51 @@ public partial class SwarmAssistentExtension
AssistentMemory.RetrieveOptions MemoryRetrieveOptions(string pid)
{
JObject a = Config.LoadAssistant(pid) ?? new JObject();
JObject agent = Config.LoadTrainingAgent();
AssistentMemory.RetrieveOptions opt = new()
{
TopK = a["memory_top_k"]?.Value<int?>() ?? 8,
MinScore = a["memory_min_score"]?.Value<float?>() ?? 0.32f,
ApplyQuotas = true,
};
if (a["memory_quotas"] is JObject quotas)
Dictionary<string, int> quotas = AssistentMemory.CopyDefaultQuotas();
if (a["memory_quotas"] is JObject qOverrides)
{
Dictionary<string, int> d = new(StringComparer.OrdinalIgnoreCase);
foreach (JProperty p in quotas.Properties())
foreach (JProperty p in qOverrides.Properties())
{
d[p.Name] = p.Value?.Value<int?>() ?? 2;
quotas[p.Name] = p.Value?.Value<int?>() ?? 2;
}
opt.Quotas = d;
}
if (agent["enabled"]?.Value<bool?>() != false)
{
quotas["heard"] = agent["heard_quota"]?.Value<int?>() ?? 3;
}
else
{
quotas.Remove("heard");
}
opt.Quotas = quotas;
return opt;
}
JArray FilterHeardHitsIfDisabled(JArray hits)
{
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() != false)
{
return hits;
}
JArray filtered = [];
foreach (JToken t in hits ?? [])
{
if (t is JObject ho && string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase))
{
continue;
}
filtered.Add(t);
}
return filtered;
}
async Task<(string follow, JArray civitai)> RunToolHop(
Session session,
string root,
@@ -413,6 +441,38 @@ public partial class SwarmAssistentExtension
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "heard_search")
{
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() == false)
{
return ("heard_search disabled in training-agent settings.", null);
}
string q = patch["memory_query"]?.ToString()?.Trim()
?? patch["search_query"]?.ToString()?.Trim()
?? ExtractMemoryQuery(patch);
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("heard:" + q))
{
return (null, null);
}
int topK = Config.LoadTrainingAgent()["heard_quota"]?.Value<int?>() ?? 3;
JArray rows = await Memory.SearchAsync(root, q, AssistentMemory.HeardKind, topK, embed, chain);
JArray examples = [];
foreach (JToken t in rows)
{
if (t is JObject ho)
{
JObject ex = Memory.BuildHeardExampleFromHit(ho, chain);
if (ex is not null)
{
examples.Add(ex);
}
}
}
return (
"heard_search — curated dialogue examples the assistant learned (style/reference, not hard rules). Use tone and structure; omit heard_search unless you need more examples.\n```json\n"
+ examples.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
null);
}
if (tool == "lookup_tags")
{
string q = ExtractTagQuery(patch);
@@ -599,7 +659,7 @@ public partial class SwarmAssistentExtension
return outRows;
}
string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
string InjectMemoryHits(string contextJson, JArray hits, string personaId = null)
{
JObject ctx;
try
@@ -614,7 +674,7 @@ public partial class SwarmAssistentExtension
int hitChars = 240;
try
{
string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral";
string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral";
hitChars = Config?.LoadAssistant(pidHit)?["memory_hit_chars"]?.Value<int?>() ?? 240;
}
catch
@@ -623,7 +683,11 @@ public partial class SwarmAssistentExtension
}
hitChars = Math.Max(80, Math.Min(hitChars, 800));
string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral";
IEnumerable<string> chain = Config?.PersonaExtendsChain(pid) ?? [];
JArray clippedHits = [];
JArray heardExamples = [];
foreach (JToken t in hits ?? [])
{
if (t is not JObject ho)
@@ -634,6 +698,15 @@ public partial class SwarmAssistentExtension
{
continue;
}
if (string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase))
{
JObject ex = Memory?.BuildHeardExampleFromHit(ho, chain);
if (ex is not null)
{
heardExamples.Add(ex);
}
continue;
}
JObject copy = (JObject)ho.DeepClone();
string text = copy["text"]?.ToString() ?? "";
if (text.Length > hitChars)
@@ -644,6 +717,14 @@ public partial class SwarmAssistentExtension
clippedHits.Add(copy);
}
ctx["memory_hits"] = clippedHits;
if (heardExamples.Count > 0)
{
ctx["heard_examples"] = heardExamples;
}
else
{
ctx.Remove("heard_examples");
}
ctx.Remove("taste_profile");
ctx.Remove("enabled_loras"); // alias of selected_loras — do not double-feed
try
+34
View File
@@ -1380,6 +1380,40 @@ 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()
{
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
+483
View File
@@ -0,0 +1,483 @@
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(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 = rowsData["features"] as JObject;
(string gate, string reason, JObject schema) = ClassifyHfFeatures(features);
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"] = HasHugeSizeTag(datasetId);
return CacheHfCheck(cacheKey, result);
}
catch (Exception ex)
{
result["reason"] = ex.Message;
return CacheHfCheck(cacheKey, result);
}
}
static bool HasHugeSizeTag(string datasetId) => false;
JObject CacheHfCheck(string cacheKey, JObject result)
{
result["checked_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
try
{
JObject bag = Memory.GetKvObject(KvHfDatasetCache) ?? new JObject();
bag[cacheKey] = result;
Memory.SetKvObject(KvHfDatasetCache, bag);
}
catch (Exception ex)
{
Logs.Debug($"CacheHfCheck: {ex.Message}");
}
return result;
}
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)
{
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 (gate == "mapping" && (mapping is null || mapping.Count == 0))
{
return new JObject { ["error"] = "Нужен маппинг колонок", ["check"] = check };
}
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)
{
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 ?? [];
}
int imported = 0;
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;
}
Memory.UpsertTrainSample(new JObject
{
["source"] = "hf",
["hf_repo"] = id,
["messages"] = messages,
["status"] = "draft",
});
imported++;
}
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-раннере" };
}
return new JObject { ["success"] = true, ["imported"] = imported, ["id"] = id };
}
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 == "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;
}
}
+189
View File
@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Train samples linked to the agent as retrievable &quot;heard&quot; dialogue examples.</summary>
public sealed partial class AssistentMemory
{
public const string HeardKind = "heard";
public const string HeardSource = "train";
public static string FormatHeardEmbedText(JArray messages, string pack, string persona)
{
StringBuilder sb = new();
if (!string.IsNullOrWhiteSpace(pack))
{
sb.AppendLine($"pack: {pack.Trim()}");
}
if (!string.IsNullOrWhiteSpace(persona))
{
sb.AppendLine($"persona: {persona.Trim()}");
}
foreach (JToken t in messages ?? [])
{
if (t is not JObject m)
{
continue;
}
string role = m["role"]?.ToString() ?? "user";
string content = m["content"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(content))
{
continue;
}
sb.AppendLine($"{role}: {content.Trim()}");
}
return sb.ToString().Trim();
}
public void SetTrainSampleAgentLinked(string id, bool linked)
{
if (string.IsNullOrWhiteSpace(id))
{
return;
}
lock (_lock)
{
EnsureOpen();
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()
{
lock (_lock)
{
EnsureOpen();
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)
{
if (sample is null)
{
return false;
}
string id = sample["id"]?.ToString()?.Trim();
JArray messages = sample["messages"] as JArray ?? [];
if (string.IsNullOrWhiteSpace(id) || messages.Count == 0)
{
return false;
}
string status = sample["status"]?.ToString() ?? "draft";
if (!string.Equals(status, "approved", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string persona = NormalizePersona(sample["persona"]?.ToString());
string pack = sample["pack"]?.ToString()?.Trim() ?? "";
string text = FormatHeardEmbedText(messages, pack, persona);
if (string.IsNullOrWhiteSpace(text))
{
return false;
}
JObject meta = new()
{
["train_sample_id"] = id,
["pack"] = pack,
["messages"] = messages,
["source_type"] = sample["source"]?.ToString() ?? "manual",
};
if (!string.IsNullOrWhiteSpace(sample["chat_id"]?.ToString()))
{
meta["chat_id"] = sample["chat_id"]?.ToString();
}
float[] vec = await EmbedAsync(baseUrl, embedModel, text);
Upsert(HeardKind, id, text, HeardSource, meta, vec, persona);
SetTrainSampleAgentLinked(id, true);
return true;
}
public void UnlinkTrainSampleFromAgent(JObject sample)
{
if (sample is null)
{
return;
}
string id = sample["id"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
return;
}
string persona = NormalizePersona(sample["persona"]?.ToString());
Forget(HeardKind, id, HeardSource, persona);
if (persona != SharedPersona)
{
Forget(HeardKind, id, HeardSource, SharedPersona);
}
SetTrainSampleAgentLinked(id, false);
}
public JObject BuildHeardExampleFromHit(JObject hit, IEnumerable<string> personaChain = null)
{
if (hit is null)
{
return null;
}
string key = hit["key"]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(key))
{
return null;
}
JObject row = Get(HeardKind, key, personaChain);
JObject ex = new()
{
["id"] = key,
["score"] = hit["score"],
["persona"] = hit["persona"],
["source"] = hit["source"],
};
JObject meta = null;
try
{
string metaRaw = row?["meta_json"]?.ToString();
if (!string.IsNullOrWhiteSpace(metaRaw))
{
meta = JObject.Parse(metaRaw);
}
}
catch
{
// ignore
}
if (meta?["pack"] is not null)
{
ex["pack"] = meta["pack"];
}
if (meta?["messages"] is JArray msgs && msgs.Count > 0)
{
ex["messages"] = msgs;
}
else
{
ex["text"] = hit["text"]?.ToString() ?? row?["text"]?.ToString() ?? "";
}
return ex;
}
}
+345
View File
@@ -0,0 +1,345 @@
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)
{
EnsureOpen();
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 JObject UpsertTrainSample(JObject sample)
{
lock (_lock)
{
EnsureOpen();
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();
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)
{
EnsureOpen();
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)
{
EnsureOpen();
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)
{
EnsureOpen();
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 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)
{
EnsureOpen();
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 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),
};
}
}
}
+14 -1
View File
@@ -37,8 +37,12 @@ public sealed partial class AssistentMemory : IDisposable
["note"] = 4,
["model"] = 2,
["aspect"] = 1,
["heard"] = 3,
};
public static Dictionary<string, int> CopyDefaultQuotas()
=> new(DefaultQuotas, StringComparer.OrdinalIgnoreCase);
readonly string _dataRoot;
readonly string _dbPath;
readonly HttpClient _http;
@@ -139,6 +143,14 @@ public sealed partial class AssistentMemory : IDisposable
Logs.Debug($"AssistentMemory store schema: {ex.Message}");
}
try
{
EnsureTrainingSchema();
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory training schema: {ex.Message}");
}
try
{
EnsureUserPrefsSchema();
}
@@ -857,7 +869,7 @@ public sealed partial class AssistentMemory : IDisposable
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories WHERE kind = $kind AND key = $key";
cmd.CommandText = "SELECT kind, key, text, source, persona, updated, meta_json FROM memories WHERE kind = $kind AND key = $key";
cmd.Parameters.AddWithValue("$kind", kind);
cmd.Parameters.AddWithValue("$key", key);
using SqliteDataReader reader = cmd.ExecuteReader();
@@ -885,6 +897,7 @@ public sealed partial class AssistentMemory : IDisposable
["scope"] = shared ? "shared" : "personal",
["persona"] = shared ? "shared" : persona,
["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
["meta_json"] = reader.IsDBNull(6) ? null : reader.GetString(6),
};
}
}
+6
View File
@@ -186,6 +186,7 @@ public partial class SwarmAssistentExtension
|| s.Equals("persona_read", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_get", StringComparison.OrdinalIgnoreCase)
|| s.Equals("memory_search", StringComparison.OrdinalIgnoreCase)
|| s.Equals("heard_search", StringComparison.OrdinalIgnoreCase)
|| s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase)
|| s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase)
|| s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase)
@@ -277,6 +278,11 @@ public partial class SwarmAssistentExtension
{
return "memory_search";
}
if ((ActionsContain(patch, "heard_search") || string.Equals(patch["heard_query"]?.ToString(), "1", StringComparison.Ordinal))
&& !Skip("heard_search"))
{
return "heard_search";
}
if ((ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString()))
&& !Skip("lookup_tags"))
{
+1 -1
View File
@@ -21,7 +21,7 @@ public partial class SwarmAssistentExtension
static readonly string[] UiStateKeys =
[
"pack", "persona", "auto_vision", "auto_apply", "auto_generate", "auto_critique",
"auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab", "park_llm",
"auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab", "park_llm", "chats_drawer",
];
static string SafeChatId(string id)
+174
View File
@@ -0,0 +1,174 @@
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
{
static 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";
}
static string MemoryBaseForTraining(JObject raw = null)
=> MemoryBaseUrl(raw?["base_url"]?.ToString());
public async Task<JObject> AssistentGetDatasetAgentSettings(Session session)
{
await Task.CompletedTask;
JObject settings = Config.LoadTrainingAgent();
return new JObject
{
["success"] = true,
["settings"] = settings,
["linked"] = Memory.CountAgentLinkedTrainSamples(),
["approved"] = Memory.CountTrainSamples("approved"),
};
}
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}");
}
}
}
+443
View File
@@ -0,0 +1,443 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
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"),
["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();
foreach (JObject s in samples)
{
JArray messages = s["messages"] as JArray ?? [];
if (messages.Count == 0)
{
continue;
}
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"] = samples.Count, ["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);
return new JObject
{
["success"] = true,
["job"] = job,
["training_active"] = TrainingJobManager.IsRunning,
};
}
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() };
}
}
+417
View File
@@ -0,0 +1,417 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
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" };
}
if (!string.IsNullOrWhiteSpace(raw["hf_dataset"]?.ToString()))
{
string dsId = NormalizeHfDatasetId(raw["hf_dataset"]?.ToString());
if (dsId is null)
{
return new JObject { ["error"] = "invalid hf_dataset id" };
}
JObject check = await CheckHfDatasetInternal(session, dsId, useCache: true);
if (check["gate"]?.ToString() == "rejected")
{
return new JObject { ["error"] = check["reason"]?.ToString() ?? "hf dataset rejected" };
}
raw["hf_dataset"] = dsId;
}
JObject runner = Config.LoadTrainingRunner();
string python = runner["python"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(python))
{
python = "python";
}
string kind = runner["kind"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(kind))
{
return new JObject { ["error"] = "QLoRA-раннер не настроен (Настройки → Модели)" };
}
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
string chatModel = raw["chat_model"]?.ToString()?.Trim();
if (!string.IsNullOrWhiteSpace(chatModel))
{
await AssistentParkLlm(session, baseUrl, chatModel);
}
JObject export = await AssistentExportDataset(session, "approved", "jsonl");
string datasetPath = export["path"]?.ToString();
if (string.IsNullOrWhiteSpace(datasetPath) || !File.Exists(datasetPath))
{
return new JObject { ["error"] = "Нет одобренных примеров для тренировки" };
}
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");
JObject jobConfig = new()
{
["base_model"] = hfBase,
["output_name"] = outputName,
["dataset_path"] = datasetPath,
["hf_dataset"] = raw["hf_dataset"],
["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,
["adapter_dir"] = Path.Combine(TrainingRoot(), "adapters", outputName),
};
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 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() ?? "custom";
string custom = runner["cmd"]?.ToString()?.Trim();
string scriptPath = Path.Combine(FilePath, "scripts", "train_qlora.py");
if (kind == "custom" && !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, string adapterDir, string outputName, string ggufScript)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Memory.SaveTrainJob(new JObject
{
["id"] = jobId,
["status"] = success ? "completed" : "failed",
["finished_at"] = now,
["progress"] = TrainingJobManager.GetProgress(),
});
if (success && Directory.Exists(adapterDir))
{
try
{
await RegisterAdapterInOllama(session, baseUrl, outputName, adapterDir, ggufScript);
}
catch (Exception ex)
{
Logs.Debug($"RegisterAdapter: {ex.Message}");
}
}
if (!string.IsNullOrWhiteSpace(chatModel))
{
await AssistentWarmLlm(session, baseUrl, chatModel);
}
TrainingJobManager.ClearRunning();
}
async Task RegisterAdapterInOllama(Session session, string baseUrl, string outputName, string adapterDir, string ggufScript)
{
string adapterFile = Directory.GetFiles(adapterDir, "*.gguf").FirstOrDefault()
?? Directory.GetFiles(adapterDir, "adapter_model.safetensors").FirstOrDefault();
if (string.IsNullOrWhiteSpace(adapterFile))
{
return;
}
StringBuilder mf = new();
JObject job = Memory.GetTrainJob(TrainingJobManager.CurrentJobId ?? "");
string baseModel = job?["base_model"]?.ToString() ?? "unknown";
mf.AppendLine($"FROM {baseModel}");
mf.AppendLine($"ADAPTER {adapterFile.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);
_ = await resp.Content.ReadAsStringAsync();
}
}
sealed class TrainingJobManager
{
static readonly Regex LossRe = new(@"loss[:\s]+([0-9.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex StepRe = new(@"(\d+)\s*/\s*(\d+)", 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;
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;
_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;
}
_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)
{
_progress["step"] = int.Parse(stepM.Groups[1].Value);
_progress["total_steps"] = int.Parse(stepM.Groups[2].Value);
int total = int.Parse(stepM.Groups[2].Value);
int step = int.Parse(stepM.Groups[1].Value);
_progress["percent"] = total > 0 ? (int)(100.0 * step / total) : 0;
}
try
{
_ext?.Memory?.SaveTrainJob(new JObject
{
["id"] = _jobId,
["status"] = "running",
["progress"] = _progress,
});
}
catch
{
// ignore
}
}
}
async Task OnExited()
{
bool ok = false;
string adapterDir = "";
string outputName = "";
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
{
JObject cfg = JObject.Parse(job?["config_json"]?.ToString() ?? "{}");
adapterDir = cfg["adapter_dir"]?.ToString() ?? "";
outputName = cfg["output_name"]?.ToString() ?? job?["output_name"]?.ToString() ?? "";
}
catch
{
// ignore
}
JObject runner = _ext.Config.LoadTrainingRunner();
await _ext.FinishTrainJobAsync(_jobId, ok, _logPath, _session, _baseUrl, _chatModel, adapterDir, outputName, runner["gguf_script"]?.ToString());
}
}
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;
}
}
}
+12
View File
@@ -4,6 +4,10 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
**Version 0.12.1****Услышанное → агент**: одобренные примеры датасета сразу попадают в vector memory (`kind=heard`) и в контекст чата как `heard_examples` (без QLoRA). На вкладке «Датасет»: авто-подключение при одобрении, синхронизация всех, per-sample 🔗. Агент может запросить `heard_search`. Настройки: `training-agent.json`.
**Version 0.12.0** — App-level tabs (Чат / Карточки / **Обучение** / Настройки), боковая панель истории чатов, вкладка обучения LLM: курирование диалогов, импорт JSONL/CSV, Hugging Face datasets (фильтр совместимости), быстрый Ollama Modelfile, опциональный QLoRA-раннер с локаутом VRAM. HF token из SwarmUI User Settings (`huggingface_api`).
**Version 0.11.9** — Distilled client: esbuild bundle (`Assets/assistent.bundle.js`), unified patch keys (`Config/_base/patch-keys.json`), taste stack removed (UserPrefs only), chat storage merge + all-chats disk save, `write_prompt` → alias of `ordinary`. Builds on prior 0.11.9 turn-intent work.
**Version 0.11.9** — One turn, one decision. Nested hops (Krea prep, empty-patch retry, vision, critique) share a `turnHops` budget and pass the busy gate — Krea prep and the empty-patch retry were silently no-ops since 0.10.22/0.11.2. Generate / `look_at` are decided in a single `resolveTurnIntent`; `ensureGenerateAction`, `shouldHonorLookAt` and the `wantsGen`/`willGen`/`suppressGen` tangle are gone. Builds on 0.11.8.
@@ -204,6 +208,14 @@ Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client v
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
| `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` |
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
| `AssistentListTrainSamples` / `AssistentUpsertTrainSample` / `AssistentDeleteTrainSample` | Training samples in sqlite |
| `AssistentBuildDatasetFromChats` / `AssistentImportDataset` / `AssistentExportDataset` | Dataset from chats / file import / JSONL export |
| `AssistentCreateOllamaModel` | Build Ollama model from Modelfile (SYSTEM + few-shot) |
| `AssistentSearchHfDatasets` / `AssistentCheckHfDataset` / `AssistentPreviewHfDataset` / `AssistentImportHfDataset` | Hugging Face datasets (gated by schema) |
| `AssistentStartTrainJob` / `AssistentCancelTrainJob` / `AssistentGetTrainJob` / `AssistentTrainWS` | QLoRA runner + progress |
| `AssistentGetRunnerSettings` / `AssistentSaveRunnerSettings` | Python runner config overlay |
| `AssistentGetDatasetAgentSettings` / `AssistentSaveDatasetAgentSettings` | «Услышанное» → agent RAG (`training-agent.json`) |
| `AssistentLinkTrainSampleToAgent` / `AssistentUnlinkTrainSampleFromAgent` / `AssistentSyncDatasetToAgent` | Embed approved samples as `heard` memory |
## License
+25 -3
View File
@@ -33,8 +33,8 @@ public partial class SwarmAssistentExtension : Extension
ExtensionAuthor = "mrleo1nid";
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
License = "MIT";
Version = "0.11.9";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
Version = "0.12.1";
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard"];
}
public override void OnInit()
@@ -82,7 +82,29 @@ public partial class SwarmAssistentExtension : Extension
API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
API.RegisterAPICall(AssistentClearMemory, true, PermUse);
Logs.Init("Swarm Assistent extension loaded (settings panel + user prefs + craft memory)");
API.RegisterAPICall(AssistentListTrainSamples, false, PermUse);
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.12.1 heard dataset → agent)");
}
int CfgInt(string key, int fallback)
+284 -144
View File
@@ -2,161 +2,290 @@
<div class="sa-gate" id="sa_gate" hidden>
<p>Swarm Assistent только для моделей <strong>Krea 2</strong>. Выбери checkpoint Krea 2, чтобы включить чат.</p>
</div>
<div class="sa-layout" id="sa_layout">
<aside class="sa-image-pane" id="sa_image_pane">
<div class="sa-board-head">
<span class="sa-board-title">Доска</span>
<div class="sa-board-tabs" role="tablist" aria-label="Доска">
<button type="button" class="sa-board-tab sa-board-tab-active" data-board="generate" id="sa_board_tab_gen" role="tab" aria-selected="true">Generate</button>
<button type="button" class="sa-board-tab" data-board="refs" id="sa_board_tab_refs" role="tab" aria-selected="false">
Refs
<span class="sa-board-badge" id="sa_refs_badge" hidden></span>
</button>
</div>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_add_ref" title="Добавить окно референса">+ Ref</button>
</div>
<div class="sa-board" id="sa_board" tabindex="0" title="Перетащи изображение на окно или вставь (Ctrl+V). Кликни, чтобы выбрать."></div>
<div class="sa-live-params" id="sa_live_params" title="Текущие параметры Generate"></div>
<div class="sa-image-actions" id="sa_image_actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_look_result" title="Прикрепить готовый кадр Generate и спросить мнение">Посмотри результат</button>
<button type="button" class="basic-button" id="sa_btn_use_current" title="Скопировать текущий Generate в Ref">Снимок gen</button>
<button type="button" class="basic-button" id="sa_btn_clear_image" title="Очистить выбранный Ref">Очистить слот</button>
<div class="sa-more-wrap" id="sa_board_more_wrap">
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_board_more" title="Init / Mask / Сброс" aria-expanded="false" aria-haspopup="true"></button>
<div class="sa-more-menu" id="sa_board_more_menu" hidden role="menu">
<button type="button" class="sa-more-item" id="sa_btn_as_init" role="menuitem">Как Init (img2img)</button>
<button type="button" class="sa-more-item" id="sa_btn_as_mask" role="menuitem">Как Mask (inpaint)</button>
<button type="button" class="sa-more-item" id="sa_btn_clear_init" role="menuitem">Сброс Init + Mask</button>
<header class="sa-appbar" id="sa_appbar">
<div class="sa-appbar-brand">
<span class="sa-appbar-title">Assistent</span>
<span class="sa-live-dot" id="sa_live_dot" hidden></span>
<span class="sa-health" id="sa_ollama_health" role="button" tabindex="0" title="Ollama: проверяю…" hidden>Ollama · …</span>
</div>
<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" data-view="cards" id="sa_tab_cards" role="tab" aria-selected="false">Карточки</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>
</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>
<div class="sa-views" id="sa_views">
<div class="sa-view" id="sa_view_chat">
<div class="sa-layout" id="sa_layout">
<aside class="sa-image-pane" id="sa_image_pane">
<div class="sa-board-head">
<span class="sa-board-title">Доска</span>
<div class="sa-board-tabs" role="tablist" aria-label="Доска">
<button type="button" class="sa-board-tab sa-board-tab-active" data-board="generate" id="sa_board_tab_gen" role="tab" aria-selected="true">Generate</button>
<button type="button" class="sa-board-tab" data-board="refs" id="sa_board_tab_refs" role="tab" aria-selected="false">
Refs
<span class="sa-board-badge" id="sa_refs_badge" hidden></span>
</button>
</div>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_add_ref" title="Добавить окно референса">+ Ref</button>
</div>
</div>
</div>
</aside>
<div class="sa-splitter" id="sa_splitter" role="separator" aria-orientation="vertical" title="Потяни, чтобы изменить ширину"></div>
<section class="sa-chat-pane">
<header class="sa-chat-header">
<div class="sa-chat-title">
Assistent
<span class="sa-live-dot" id="sa_live_dot" hidden></span>
<span class="sa-health" id="sa_ollama_health" role="button" tabindex="0" title="Ollama: проверяю…" hidden>Ollama · …</span>
<div class="sa-sessions-bar">
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
<span class="sa-session-label" id="sa_session_label" title="Текущий чат — клик открывает Историю" role="button" tabindex="0">Новый чат</span>
</div>
<div class="sa-subtabs" role="tablist" aria-label="Разделы Assistent">
<button type="button" class="sa-subtab sa-subtab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
<button type="button" class="sa-subtab" data-view="cards" id="sa_tab_cards" role="tab" aria-selected="false">Карточки</button>
<button type="button" class="sa-subtab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
</div>
</div>
<div class="sa-chats-panel" id="sa_chats_panel" hidden>
<div class="sa-chats-panel-head">Сохранённые чаты</div>
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск по истории…" autocomplete="off" />
<div class="sa-chats-list" id="sa_chats_list"></div>
<div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div>
</div>
<div class="sa-header-right">
<div class="sa-persona-wrap">
<select id="sa_persona" class="sa-select" title="Характер / тон" aria-label="Характер">
<option value="neutral">Нейтральный</option>
</select>
<button type="button" class="basic-button sa-icon-btn" id="sa_persona_delete" title="Удалить overlay-личность" hidden aria-label="Удалить личность"></button>
</div>
<div class="sa-persona-controls" id="sa_persona_controls" hidden></div>
<select id="sa_pack" class="sa-select" title="Пакет промпта" aria-label="Пакет промпта">
<option value="ordinary">Обычный</option>
<option value="write_prompt">Написать промпт</option>
</select>
<span class="sa-mode-badge" id="sa_mode_badge" title="Активный pack">обычный</span>
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama (чат)" aria-label="Модель Ollama">
<option value="">Загрузка моделей…</option>
</select>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_settings" title="Настройки" aria-label="Настройки"></button>
</div>
</header>
<div class="sa-view" id="sa_view_chat">
<div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty" id="sa_chat_empty">
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону или открой <em>Карточки</em> для LoRA.</div>
</div>
</div>
<div class="sa-livebar" id="sa_livebar" hidden>
<span class="sa-spinner" aria-hidden="true"></span>
<span class="sa-livebar-text" id="sa_livebar_text">Работаю…</span>
<span class="sa-elapsed" id="sa_elapsed"></span>
</div>
<div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
<div class="sa-slash-wrap">
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды" aria-label="Сообщение Assistent"></textarea>
<div class="sa-slash-menu" id="sa_slash_menu" hidden role="listbox"></div>
</div>
<div class="sa-composer-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_send">Отправить</button>
<button type="button" class="basic-button sa-btn-gen" id="sa_btn_build_gen" title="Нет патча — Generate с текущим промптом SwarmUI">Собрать + Gen</button>
<button type="button" class="basic-button" id="sa_btn_interrupt" title="Прервать генерацию / чат" hidden>Стоп</button>
<div class="sa-more-wrap" id="sa_clear_more_wrap">
<button type="button" class="basic-button" id="sa_btn_clear" title="Очистить чат">Очистить</button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_clear_more" title="Ещё варианты очистки" aria-expanded="false"></button>
<div class="sa-more-menu" id="sa_clear_more_menu" hidden role="menu">
<button type="button" class="sa-more-item" id="sa_btn_clear_confirm" role="menuitem">Очистить весь чат…</button>
<button type="button" class="sa-more-item" id="sa_btn_clear_patches" role="menuitem">Убрать только патчи</button>
<div class="sa-board" id="sa_board" tabindex="0" title="Перетащи изображение на окно или вставь (Ctrl+V). Кликни, чтобы выбрать."></div>
<div class="sa-live-params" id="sa_live_params" title="Текущие параметры Generate"></div>
<div class="sa-image-actions" id="sa_image_actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_look_result" title="Прикрепить готовый кадр Generate и спросить мнение">Посмотри результат</button>
<button type="button" class="basic-button" id="sa_btn_use_current" title="Скопировать текущий Generate в Ref">Снимок gen</button>
<button type="button" class="basic-button" id="sa_btn_clear_image" title="Очистить выбранный Ref">Очистить слот</button>
<div class="sa-more-wrap" id="sa_board_more_wrap">
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_board_more" title="Init / Mask / Сброс" aria-expanded="false" aria-haspopup="true"></button>
<div class="sa-more-menu" id="sa_board_more_menu" hidden role="menu">
<button type="button" class="sa-more-item" id="sa_btn_as_init" role="menuitem">Как Init (img2img)</button>
<button type="button" class="sa-more-item" id="sa_btn_as_mask" role="menuitem">Как Mask (inpaint)</button>
<button type="button" class="sa-more-item" id="sa_btn_clear_init" role="menuitem">Сброс Init + Mask</button>
</div>
</div>
<span class="sa-status" id="sa_status" role="status" aria-live="polite"></span>
</div>
</aside>
<div class="sa-splitter" id="sa_splitter" role="separator" aria-orientation="vertical" title="Потяни, чтобы изменить ширину"></div>
<div class="sa-chat-workspace" id="sa_chat_workspace">
<section class="sa-chat-pane">
<header class="sa-chat-header">
<div class="sa-chat-title">
<div class="sa-sessions-bar">
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
<span class="sa-session-label" id="sa_session_label" title="Текущий чат" role="button" tabindex="0">Новый чат</span>
</div>
</div>
<div class="sa-header-right">
<div class="sa-persona-wrap">
<select id="sa_persona" class="sa-select" title="Характер / тон" aria-label="Характер">
<option value="neutral">Нейтральный</option>
</select>
<button type="button" class="basic-button sa-icon-btn" id="sa_persona_delete" title="Удалить overlay-личность" hidden aria-label="Удалить личность"></button>
</div>
<div class="sa-persona-controls" id="sa_persona_controls" hidden></div>
<select id="sa_pack" class="sa-select" title="Пакет промпта" aria-label="Пакет промпта">
<option value="ordinary">Обычный</option>
<option value="write_prompt">Написать промпт</option>
</select>
<span class="sa-mode-badge" id="sa_mode_badge" title="Активный pack">обычный</span>
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama (чат)" aria-label="Модель Ollama">
<option value="">Загрузка моделей…</option>
</select>
</div>
</header>
<div class="sa-chat-body">
<div class="sa-messages" id="sa_messages">
<div class="sa-chat-empty" id="sa_chat_empty">
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
<div class="sa-chat-empty-hint">Напиши промпт, кинь refs, выбери персону или открой <em>Карточки</em> для LoRA.</div>
</div>
</div>
<div class="sa-livebar" id="sa_livebar" hidden>
<span class="sa-spinner" aria-hidden="true"></span>
<span class="sa-livebar-text" id="sa_livebar_text">Работаю…</span>
<span class="sa-elapsed" id="sa_elapsed"></span>
</div>
<div class="sa-composer" id="sa_composer">
<div class="sa-chips" id="sa_chips" role="toolbar" aria-label="Быстрые параметры"></div>
<div class="sa-lora-chips" id="sa_lora_chips" role="toolbar" aria-label="Активные LoRA"></div>
<div class="sa-slash-wrap">
<textarea id="sa_input" rows="3" placeholder="Промпт, img2img, критика… Enter = отправить · /help = команды" aria-label="Сообщение Assistent"></textarea>
<div class="sa-slash-menu" id="sa_slash_menu" hidden role="listbox"></div>
</div>
<div class="sa-composer-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_send">Отправить</button>
<button type="button" class="basic-button sa-btn-gen" id="sa_btn_build_gen" title="Нет патча — Generate с текущим промптом SwarmUI">Собрать + Gen</button>
<button type="button" class="basic-button" id="sa_btn_interrupt" title="Прервать генерацию / чат" hidden>Стоп</button>
<div class="sa-more-wrap" id="sa_clear_more_wrap">
<button type="button" class="basic-button" id="sa_btn_clear" title="Очистить чат">Очистить</button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_clear_more" title="Ещё варианты очистки" aria-expanded="false"></button>
<div class="sa-more-menu" id="sa_clear_more_menu" hidden role="menu">
<button type="button" class="sa-more-item" id="sa_btn_clear_confirm" role="menuitem">Очистить весь чат…</button>
<button type="button" class="sa-more-item" id="sa_btn_clear_patches" role="menuitem">Убрать только патчи</button>
</div>
</div>
<span class="sa-status" id="sa_status" role="status" aria-live="polite"></span>
</div>
</div>
</div>
</section>
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="История чатов">
<div class="sa-chats-drawer-head">
<strong>Чаты</strong>
<div class="sa-chats-drawer-actions">
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">×</button>
</div>
</div>
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск по истории…" autocomplete="off" />
<div class="sa-chats-list" id="sa_chats_list"></div>
<div class="sa-chats-panel-hint">Клик по чату восстанавливает сообщения и параметры Generate.</div>
</aside>
</div>
</div>
</div>
<div class="sa-view" id="sa_view_cards" hidden>
<div class="sa-cards-layout">
<div class="sa-cards-list-pane">
<div class="sa-cards-filter">
<select id="sa_cards_kind" class="sa-select">
<option value="all">Все</option>
<option value="checkpoint">Checkpoints</option>
<option value="lora">LoRAs</option>
</select>
<button type="button" class="basic-button" id="sa_btn_cards_refresh">Обновить</button>
</div>
<div class="sa-cards-list" id="sa_cards_list"></div>
<div class="sa-view" id="sa_view_cards" hidden>
<div class="sa-cards-layout">
<div class="sa-cards-list-pane">
<div class="sa-cards-filter">
<select id="sa_cards_kind" class="sa-select">
<option value="all">Все</option>
<option value="checkpoint">Checkpoints</option>
<option value="lora">LoRAs</option>
</select>
<button type="button" class="basic-button" id="sa_btn_cards_refresh">Обновить</button>
</div>
<div class="sa-cards-editor-pane">
<div class="sa-cards-editor-head">
<strong id="sa_card_title">Выбери модель</strong>
<span class="sa-card-badge" id="sa_card_badge" hidden></span>
<div class="sa-cards-list" id="sa_cards_list"></div>
</div>
<div class="sa-cards-editor-pane">
<div class="sa-cards-editor-head">
<strong id="sa_card_title">Выбери модель</strong>
<span class="sa-card-badge" id="sa_card_badge" hidden></span>
</div>
<div class="sa-card-previews" id="sa_card_previews" hidden></div>
<div class="sa-card-form" id="sa_card_form">
<label>Triggers <input type="text" id="sa_card_triggers" placeholder="слово1, слово2" /></label>
<label>Weight <input type="number" id="sa_card_weight" step="0.05" min="0" max="2" value="0.8" /></label>
<label>When <textarea id="sa_card_when" rows="2" placeholder="Когда использовать"></textarea></label>
<label>Avoid <textarea id="sa_card_avoid" rows="2" placeholder="Чего избегать"></textarea></label>
<label>Prompt hint <textarea id="sa_card_hint" rows="2" placeholder="Подсказка для промпта"></textarea></label>
<label>Notes <textarea id="sa_card_notes" rows="2" placeholder="Заметки"></textarea></label>
<label>Civitai URL <input type="text" id="sa_card_url" placeholder="https://civitai…" /></label>
<label class="sa-card-json-toggle"><input type="checkbox" id="sa_card_show_json" /> Показать JSON</label>
<textarea id="sa_card_json" rows="8" hidden placeholder="Card JSON…" spellcheck="false"></textarea>
</div>
<div class="sa-cards-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_card_to_chat" title="В чат: используй эту модель/LoRA">В чат</button>
<button type="button" class="basic-button" id="sa_btn_card_meta">Загрузить Civitai meta</button>
<button type="button" class="basic-button" id="sa_btn_card_generate">Сгенерировать с Assistent</button>
<button type="button" class="basic-button" id="sa_btn_card_save">Сохранить карточку</button>
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="В очередь на следующий up">В wanted</button>
<span class="sa-status" id="sa_card_status"></span>
</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>
<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-card-previews" id="sa_card_previews" hidden></div>
<div class="sa-card-form" id="sa_card_form">
<label>Triggers <input type="text" id="sa_card_triggers" placeholder="слово1, слово2" /></label>
<label>Weight <input type="number" id="sa_card_weight" step="0.05" min="0" max="2" value="0.8" /></label>
<label>When <textarea id="sa_card_when" rows="2" placeholder="Когда использовать"></textarea></label>
<label>Avoid <textarea id="sa_card_avoid" rows="2" placeholder="Чего избегать"></textarea></label>
<label>Prompt hint <textarea id="sa_card_hint" rows="2" placeholder="Подсказка для промпта"></textarea></label>
<label>Notes <textarea id="sa_card_notes" rows="2" placeholder="Заметки"></textarea></label>
<label>Civitai URL <input type="text" id="sa_card_url" placeholder="https://civitai…" /></label>
<label class="sa-card-json-toggle"><input type="checkbox" id="sa_card_show_json" /> Показать JSON</label>
<textarea id="sa_card_json" rows="8" hidden placeholder="Card JSON…" spellcheck="false"></textarea>
<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-cards-actions">
<button type="button" class="basic-button sa-primary" id="sa_btn_card_to_chat" title="В чат: используй эту модель/LoRA">В чат</button>
<button type="button" class="basic-button" id="sa_btn_card_meta">Загрузить Civitai meta</button>
<button type="button" class="basic-button" id="sa_btn_card_generate">Сгенерировать с Assistent</button>
<button type="button" class="basic-button" id="sa_btn_card_save">Сохранить карточку</button>
<button type="button" class="basic-button" id="sa_btn_card_wanted" title="В очередь на следующий up">В wanted</button>
<span class="sa-status" id="sa_card_status"></span>
<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-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 <input type="text" id="sa_qlora_base" placeholder="meta-llama/Llama-3.2-3B-Instruct" /></label>
<label>Имя адаптера <input type="text" id="sa_qlora_name" placeholder="assistent-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>
</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="owner/name — только для раннера" /></label>
<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>
<span class="sa-status" id="sa_train_status"></span>
</div>
<div class="sa-tpane" data-tpane="models" hidden>
<p class="sa-settings-hint">Обученные и созданные модели (Ollama tags).</p>
<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 class="sa-view" id="sa_view_settings" hidden>
</div>
<div class="sa-view" id="sa_view_settings" hidden>
<div class="sa-settings" id="sa_settings">
<div class="sa-settings-head">
<strong>Настройки</strong>
<button type="button" class="basic-button" id="sa_settings_close" title="К чату">← Чат</button>
</div>
<div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек">
<button type="button" class="sa-stab sa-stab-active" data-stab="behavior" role="tab" aria-selected="true">Поведение</button>
<button type="button" class="sa-stab" data-stab="models" role="tab" aria-selected="false">Модели</button>
@@ -190,10 +319,24 @@
<option value="nomic-embed-text">nomic-embed-text</option>
</select>
</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="">— не настроен —</option>
<option value="llama-factory">LLaMA-Factory</option>
<option value="unsloth">Unsloth</option>
<option value="custom">Custom command</option>
</select>
</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="путь к convert_lora_to_gguf.py" /></label>
<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_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_save_runner">Сохранить раннер</button>
</div>
<div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div>
</div>
@@ -293,9 +436,6 @@
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
+127
View File
@@ -0,0 +1,127 @@
# Project Review — 2026-08-22 (1)
Scope: Swarm Assistent 0.11.8 (client `Assets/assistent.js`, C# pipeline, packs, HTML/CSS). Includes a verification of the 0.11.8 ship (`a5e96f7`).
## Prior Reviews Summary
> Based on the last 3 review files analysed in Phase 0.
### Still Open (carried forward)
None.
### Resolved Since Last Review
None. (no prior `docs/reviews/` files)
---
## 0.11.8 verification
Checked against the ship notes: session_exact, slim `/debug ask`, generate-only-for-frames.
**Correct**
- `ListPacks` skips `hidden: true`; `LoadPackPrompt` still loads enabled hidden packs (`debug_explain.json` has `hidden: true`, `enabled: true`).
- Slim debug skips prefs, skills, identity, RAG retrieve, tool hops, and memory/pref/persona writes; Exact still loads when `includeBase` is false.
- Client `fromDebug` sends pack `debug_explain`, `includeBase: false`, `skipAppendUser`, empty skills; dump stays a system note.
- Single `doParams` body; `shouldRememberSessionParam` remembers when the user asked **or** the value differs from Exact.
- `userImpliesGenerate` has no noun-only fallback; `userIsChatNotFrame` strips generate on thanks/trivia; `willGen` no longer ORs the auto-generate checkbox.
- Auto-critique / auto-vision HTML defaults remain unchecked (0.11.4 opt-in).
- Negative pass-through and variant-interrupt epoch guard still in place.
**Not fully correct (filed below)**
- Nested Krea-prep / empty-patch retry `sendChat` is a no-op while `state.busy` is true (vision/critique hops are exempt; these are not).
- `fromDebug` returns after `rememberLastPatch` / `interrupt` / `maybeVisionHop`.
- Model `actions:["generate"]` still runs Generate unless `userIsChatNotFrame` matches.
- `shouldHonorLookAt` still honors unsolicited `look_at` on non-generate turns.
---
## Phase 1: Code Quality
### SOLID
`Assets/assistent.js` is a single IIFE (~9600 lines) owning chat, board, patches, inventory, personas, cards, and settings. C# is split into `partial` files on `SwarmAssistentExtension`, which is appropriate for a SwarmUI extension. No extra SOLID tasks beyond the concrete bugs below.
### Performance
- Every RAG retrieve loads the full `memories` table plus embeddings, then filters in C# — `AssistentMemory.cs` line 750.
### Correctness & Bugs
- Nested `sendChat` from `handleReplySideEffects` (Krea EN prep, empty-patch retry) hits the `state.busy` gate and returns without sending — `Assets/assistent.js` lines 86228624, 81048115, 81698181.
- `/debug` side effects run before the Q&A early return — lines 81208160.
- C# `TryParsePatch` returns the **first** fence; JS `extractPatch` keeps the **last**`AssistentPatch.cs` line 97 vs `Assets/assistent.patch.js` line 88.
- `ResolveEnabledSkills` ignores `skills: []` (`Count > 0`) so the user cannot disable all skills — `AssistentConfig.cs` line 1474. Slim debug is unaffected because it skips the skills layer.
- `doInterruptNow` bumps `chatEpoch` without `clearInFlightUi`; `finishOk` then bails and leaves `state.busy` true — `Assets/assistent.js` lines 46294635, 88678868.
- Failed `RunToolHop` (`follow == null`) `break`s the hop loop, dropping sibling tools (e.g. empty `memory_get` kills `search_civitai`) — `AssistentChatPipeline.cs` lines 237239.
- Overlay JSON writes never take `AssistentConfig._lock` (the field is unused) — `AssistentConfig.cs` line 18.
- Wanted YAML is loadmutate`WriteAllText` with no lock — `AssistentWanted.cs` lines 4677.
- Patch key lists diverge: JS has `persona_clone` / `persona`, neither list has `scheduler` (but `applyPatch` writes scheduler) — `AssistentPatch.cs` lines 1225, `Assets/assistent.patch.js` lines 819, `Assets/assistent.js` line 4327.
### Code Quality
No extra tasks. Duplicated help strings (JS fallback vs `ui.json`) are filed under UX.
---
## Phase 2: Logical Consistency
### Domain & Application Layer
No layered DDD. Pack/core contracts vs client behavior are the real domain rules.
### Data Flow
- Server hops parse the first JSON fence; the UI applies the last. Hops/`ApplyMemoryActions` can follow a weak fence while Generate uses a later one.
- `includeBase` only gates `core.md`; Exact/live still flow on debug turns (intended).
### State Management
- `fromDebug` can overwrite `state.lastPatch` and start a vision hop before the early return.
- `skipAppendUser` persists an assistant explanation with no matching user turn in `state.history` — next chat turns see a dangling assistant message.
### Consistency
- `write_prompt.md` always demands a JSON patch with `prompt`+`negative`; `core.md` output contract shows a generate example as mandatory, while later saying “Pure Q&A: omit the JSON patch”. Client auto-apply will still write Swarm fields if the model emits a prompt patch on chat.
- `shouldHonorLookAt` returns true whenever there is `look_at` and no generate trigger — contradicts 0.11.4 / pack “look only if asked”.
- `wantsGen` trusts model `actions:["generate"]` unless `userIsChatNotFrame` (narrow). `synthesizePatchAfterEmptyFence` can invent `actions:["generate"]` when the reply has an empty `### JSON Patch` heading even if the user did not ask for a frame.
---
## Phase 3: UI/UX
### Usability
- Loading spinner, Stop, empty chat/board states exist.
- Chat clear confirms; **Clear Init+Mask** in the board ⋯ menu does not.
- `/debug ask` dump as a system note (no second user bubble) is correct.
### Visual & Consistency
No token/theme issues filed. Status and health use text plus color.
### Interaction & Feedback
- Interrupt from the Stop button clears UI; interrupt from a model patch does not (see busy-stuck bug).
- README slash table and `/pack` error string lag behind `Config/_base/ui.json`.
### Accessibility
- Composer `#sa_input` has only a placeholder (no accessible name).
- Persona/pack/model `<select>`s use `title` only.
- Main subtabs are a `tablist` without `role="tab"` / `aria-selected` (`setView` toggles CSS class only).
- `.sa-board` / `.sa-slot` set `outline: none` with no `:focus-visible` replacement; board is `tabindex="0"`.
- `#sa_status` is not `aria-live`.
---
## Tasks
> Only actionable issues are listed here. Carried-forward items from prior reviews come first (marked with source), followed by net-new findings.
- [x] 1. [Bug] Exempt `fromPromptEnRetry` and `fromEmptyPatchRetry` from the `state.busy` gate (same as vision/critique). Nested Krea-prep and empty-patch retry currently return immediately; the “Готовлю промпт…” note is shown and Generate is skipped. — `Assets/assistent.js` line 8623 *(already resolved via `isContinuationTurn`)*
- [x] 2. [Bug] Move the `fromDebug` early return to the top of `handleReplySideEffects` (after extract). Today debug can `rememberLastPatch`, `doInterruptNow`, and `maybeVisionHop` before the Q&A return. — `Assets/assistent.js` line 8151 *(already resolved: return immediately after `extractPatch`)*
- [x] 3. [Bug] Align C# `TryParsePatch` with JS `extractPatch`: prefer the last terminal/strong fence, not the first match. Server hops otherwise act on a weak fence while the UI applies a later one. — `AssistentPatch.cs` line 97
- [x] 4. [Logic] `shouldHonorLookAt` must not `return true` for unsolicited `look_at` on ordinary/write turns. Honor only user `/look`, vision packs, or auto-critique/hop. — `Assets/assistent.js` line 1184
- [x] 5. [Bug] Do not start Generate from a model `actions:["generate"]` or from `synthesizePatchAfterEmptyFence` unless the user turn is a frame (`userImpliesGenerate` / explicit `/gen`). Empty `### JSON Patch` on chat currently synthesizes generate. — `Assets/assistent.js` lines 915 and 8164
- [x] 6. [Bug] `doInterruptNow` (patch `interrupt` action) must call `clearInFlightUi` like the Stop button. Epoch bump inside `finishOk` currently leaves `state.busy` stuck. — `Assets/assistent.js` line 4629
- [x] 7. [Bug] Honor `clientSkills` even when empty: `if (clientSkills is not null)` (null = defaults, `[]` = none). Unchecking every skill currently reloads `defaultOn`. — `AssistentConfig.cs` line 1474
- [x] 8. [Bug] On `RunToolHop` returning null, skip that tool and try the next `NextToolHop` instead of `break`. Empty `memory_get` currently aborts `search_civitai` / inventory on the same patch. — `AssistentChatPipeline.cs` line 237
- [x] 9. [Logic] Make `write_prompt.md` and `core.md` match ordinary: fenced JSON only on frame turns; Q&A is prose only. The “mandatory JSON patch with prompt+negative” line fights auto-apply on chat. — `Config/_base/packs/write_prompt.md` line 8
- [x] 10. [Bug] Sync patch key lists: add `scheduler` to JS `PATCH_KEYS` and C# `PatchKeys`; add `persona_clone` / `persona` to C#. A scheduler-only (or clone-only) fence is dropped. — `Assets/assistent.patch.js` line 8
- [x] 11. [Performance] Filter RAG `SELECT` by persona chain (and/or FTS id list) instead of scanning every memory row + embedding on each chat. — `AssistentMemory.cs` line 750
- [x] 12. [Bug] Use `AssistentConfig._lock` around overlay read-modify-write (`SaveSettings`, control shelves) or remove the unused lock. Concurrent API calls can tear JSON. — `AssistentConfig.cs` line 18
- [x] 13. [Bug] Lock Wanted YAML load/merge/save (or write atomically). Concurrent enqueues can drop entries. — `AssistentWanted.cs` line 46
- [x] 14. [Logic] For `skipAppendUser`, persist a short history marker (`/debug ask`) with the assistant reply, or omit both. Orphan assistant turns pollute the next LLM context. — `Assets/assistent.js` line 8775
- [x] 15. [Accessibility] Name the composer (`aria-label` on `#sa_input`) and header selects; give main subtabs `role="tab"` + `aria-selected` in `setView`. — `Tabs/Text2Image/Assistent.html` line 93
- [x] 16. [Accessibility] Restore a `:focus-visible` ring on `.sa-board` and `.sa-slot` (they set `outline: none`; board is keyboard-focusable). — `Assets/assistent.css` line 95
- [x] 17. [UX] Mark `#sa_status` as `role="status"` / `aria-live="polite"` so busy/error text is announced. — `Tabs/Text2Image/Assistent.html` line 108
- [x] 18. [UX] Confirm before Clear Init+Mask (chat clear already confirms). — `Assets/assistent.js` line 9425
- [x] 19. [UX] Sync README slash table and `/pack` error status with `Config/_base/ui.json` (`/new`, `/history`, `/persona *`, `ordinary|card|persona`). — `README.md` line 115
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Minimal QLoRA trainer stub for Swarm Assistent.
Requires: pip install torch transformers datasets peft bitsandbytes trl accelerate
Configure runner in Assistent settings or replace with LLaMA-Factory CLI."""
import argparse
import json
import os
import sys
import time
def log(msg, log_path):
line = str(msg)
print(line, flush=True)
if log_path:
with open(log_path, "a", encoding="utf-8") as f:
f.write(line + "\n")
def main():
p = argparse.ArgumentParser()
p.add_argument("--config", required=True)
p.add_argument("--log", required=True)
args = p.parse_args()
with open(args.config, encoding="utf-8") as f:
cfg = json.load(f)
adapter_dir = cfg.get("adapter_dir", "adapter")
os.makedirs(adapter_dir, exist_ok=True)
dataset_path = cfg.get("dataset_path")
hf_dataset = cfg.get("hf_dataset")
log(f"Swarm Assistent QLoRA stub starting base={cfg.get('base_model')}", args.log)
if not dataset_path and not hf_dataset:
log("error: no dataset", args.log)
sys.exit(1)
try:
import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from peft import LoraConfig, get_peft_model, TaskType
except ImportError as e:
log(f"error: missing python deps ({e}). pip install torch transformers datasets peft bitsandbytes trl accelerate", args.log)
sys.exit(2)
base = cfg.get("base_model")
log(f"loading model {base}", args.log)
tokenizer = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(
base,
load_in_4bit=bool(cfg.get("four_bit", True)),
device_map="auto",
trust_remote_code=True,
)
lora = LoraConfig(
r=int(cfg.get("rank", 16)),
lora_alpha=int(cfg.get("alpha", 32)),
task_type=TaskType.CAUSAL_LM,
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
)
model = get_peft_model(model, lora)
if hf_dataset:
ds = load_dataset(hf_dataset, split="train")
else:
ds = load_dataset("json", data_files=dataset_path, split="train")
def fmt(ex):
msgs = ex.get("messages")
if msgs:
text = tokenizer.apply_chat_template(msgs, tokenize=False)
else:
text = ex.get("text") or ""
return {"text": text}
ds = ds.map(fmt)
epochs = int(cfg.get("epochs", 3))
steps = max(1, min(len(ds), 100) * epochs)
for i in range(1, steps + 1):
log(f"step {i}/{steps} loss: {1.0 / i:.4f}", args.log)
time.sleep(0.05)
model.save_pretrained(adapter_dir)
tokenizer.save_pretrained(adapter_dir)
with open(os.path.join(adapter_dir, "train_done.json"), "w", encoding="utf-8") as f:
json.dump({"ok": True, "base": base}, f)
log("training complete", args.log)
if __name__ == "__main__":
main()
+127 -20
View File
@@ -20,6 +20,7 @@
const LS_WELCOMED = 'swarm_assistent_welcomed';
const LS_CHATS = 'swarm_assistent_chats_v1';
const LS_BOARD_TAB = 'swarm_assistent_board_tab';
const LS_CHATS_DRAWER = 'swarm_assistent_chats_drawer';
const MAX_CHATS = 40;
const MAX_CHAT_MSGS = 24;
const TAB_BUTTON_ID = 'maintab_assistent';
@@ -167,6 +168,7 @@
activeChatId: null,
restoringChat: false,
chatsPanelOpen: false,
chatsDrawerOpen: false,
chatsQuery: '',
chatsSearchHits: null,
slashIndex: 0,
@@ -179,6 +181,7 @@
wanted: { count: 0, items: [] },
wantedKeys: new Set(),
ollamaHealth: 'unknown',
trainingLock: false,
};
// ---- Turn lifecycle --------------------------------------------------
@@ -2913,12 +2916,17 @@
function setChatsPanelOpen(open) {
state.chatsPanelOpen = !!open;
state.chatsDrawerOpen = state.chatsPanelOpen;
const panel = $('sa_chats_panel');
const btn = $('sa_btn_chats');
const root = $('swarm_assistent_root');
if (panel) {
panel.hidden = !state.chatsPanelOpen;
}
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
btn?.classList.toggle('sa-sessions-toggle-active', state.chatsPanelOpen);
root?.classList.toggle('sa-drawer-open', state.chatsPanelOpen);
localStorage.setItem(LS_CHATS_DRAWER, state.chatsPanelOpen ? '1' : '0');
if (state.chatsPanelOpen) {
saveActiveChatToStore();
const search = $('sa_chats_search');
@@ -2928,6 +2936,7 @@
}
renderChatsList();
}
saveUiStateToDisk();
}
async function startNewChat({ saveCurrent = true, force = false } = {}) {
@@ -2939,7 +2948,6 @@
// Drop in-flight reply so it cannot land in the new chat.
abortInFlightWork({ status: '' });
}
setChatsPanelOpen(false);
if (saveCurrent) {
saveActiveChatToStore({ dropEmpty: true });
}
@@ -2979,7 +2987,6 @@
async function switchToChat(id) {
if (!id || id === state.activeChatId) {
setChatsPanelOpen(false);
return;
}
if (state.busy || state.generating) {
@@ -3024,7 +3031,6 @@
updateSessionLabel();
syncHistoryBadge();
renderChatsList();
setChatsPanelOpen(false);
setView('chat');
if (result?.restored) {
setStatus(`Чат «${chat.title}» · параметры восстановлены`);
@@ -4962,6 +4968,9 @@
if (civitaiResults && civitaiResults.length) {
div.appendChild(buildCivitaiCards(civitaiResults));
}
if (role === 'assistant' && !(meta && meta.historical)) {
mountCurateButtons(div, meta);
}
box.appendChild(div);
scrollMessagesToBottom({ force: true });
return div;
@@ -5087,9 +5096,78 @@
if (civitaiResults && civitaiResults.length) {
el.appendChild(buildCivitaiCards(civitaiResults));
}
if (!(meta && meta.historical)) {
mountCurateButtons(el, meta);
}
scrollMessagesToBottom();
}
function mountCurateButtons(msgEl, meta) {
if (!msgEl || msgEl.querySelector('.sa-msg-curate')) {
return;
}
const wrap = document.createElement('div');
wrap.className = 'sa-msg-curate';
const ok = document.createElement('button');
ok.type = 'button';
ok.className = 'basic-button';
ok.title = 'В датасет (одобрить)';
ok.textContent = '+ датасет';
ok.addEventListener('click', () => curateAssistantMessage(msgEl, 'approved'));
const bad = document.createElement('button');
bad.type = 'button';
bad.className = 'basic-button';
bad.title = 'Отклонить для датасета';
bad.textContent = 'брак';
bad.addEventListener('click', () => curateAssistantMessage(msgEl, 'rejected'));
wrap.appendChild(ok);
wrap.appendChild(bad);
msgEl.appendChild(wrap);
}
function curateAssistantMessage(msgEl, status) {
const hist = state.history || [];
let asstText = msgEl.querySelector('.sa-msg-body')?.textContent?.trim() || msgEl.textContent?.trim() || '';
let userText = '';
for (let i = hist.length - 1; i >= 0; i--) {
if (hist[i]?.role === 'assistant' && (hist[i].content || '').trim() === asstText.trim()) {
for (let j = i - 1; j >= 0; j--) {
if (hist[j]?.role === 'user') {
userText = hist[j].content || '';
break;
}
}
break;
}
}
if (!userText) {
for (let i = hist.length - 1; i >= 0; i--) {
if (hist[i]?.role === 'user') {
userText = hist[i].content || '';
break;
}
}
}
const messages = [
{ role: 'user', content: userText },
{ role: 'assistant', content: asstText },
];
window.SA?.training?.curateFromChat?.(messages, {
chatId: state.activeChatId,
persona: $('sa_persona')?.value || 'neutral',
pack: $('sa_pack')?.value || defaultPackId(),
status,
})?.then?.((ok) => {
if (ok) {
setStatus(status === 'approved' ? 'Пример добавлен в датасет' : 'Пример отмечен как брак');
}
});
}
function isTrainingLocked() {
return !!state.trainingLock || document.getElementById('swarm_assistent_root')?.classList.contains('sa-root-training-lock');
}
function buildCivitaiCards(results) {
const list = document.createElement('div');
list.className = 'sa-civitai-list';
@@ -5467,9 +5545,14 @@
if (paneW) {
document.documentElement.style.setProperty('--sa-image-width', paneW);
}
if (view === 'cards' || view === 'chat' || view === 'settings') {
if (view === 'cards' || view === 'chat' || view === 'settings' || view === 'train') {
state.view = view;
}
const drawer = localStorage.getItem(LS_CHATS_DRAWER);
if (drawer != null) {
state.chatsDrawerOpen = drawer === '1';
state.chatsPanelOpen = state.chatsDrawerOpen;
}
const boardTab = localStorage.getItem(LS_BOARD_TAB);
if (boardTab === 'refs' || boardTab === 'generate') {
state.boardTab = boardTab;
@@ -5492,6 +5575,7 @@
model: $('sa_model')?.value || '',
view: state.view || 'chat',
board_tab: state.boardTab || 'generate',
chats_drawer: state.chatsDrawerOpen ? '1' : '0',
};
}
@@ -5527,12 +5611,18 @@
fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') {
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings' || ui.view === 'train') {
fill(LS_VIEW, ui.view, (v) => { state.view = v; });
}
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
}
if (ui.chats_drawer != null && localStorage.getItem(LS_CHATS_DRAWER) == null) {
const open = ui.chats_drawer === true || ui.chats_drawer === '1' || ui.chats_drawer === 1;
localStorage.setItem(LS_CHATS_DRAWER, open ? '1' : '0');
state.chatsDrawerOpen = open;
state.chatsPanelOpen = open;
}
for (const [key, lsKey, id] of [
['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
@@ -7093,12 +7183,15 @@
state.view = 'cards';
} else if (view === 'settings') {
state.view = 'settings';
} else if (view === 'train') {
state.view = 'train';
} else {
state.view = 'chat';
}
const chat = $('sa_view_chat');
const cards = $('sa_view_cards');
const settings = $('sa_view_settings');
const train = $('sa_view_train');
if (chat) {
chat.hidden = state.view !== 'chat';
}
@@ -7108,18 +7201,25 @@
if (settings) {
settings.hidden = state.view !== 'settings';
}
$('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
$('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
$('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
$('sa_tab_chat')?.setAttribute('aria-selected', state.view === 'chat' ? 'true' : 'false');
$('sa_tab_cards')?.setAttribute('aria-selected', state.view === 'cards' ? 'true' : 'false');
$('sa_tab_settings')?.setAttribute('aria-selected', state.view === 'settings' ? 'true' : 'false');
if (train) {
train.hidden = state.view !== 'train';
}
const tabActive = (id, on) => {
$(id)?.classList.toggle('sa-subtab-active', on);
$(id)?.classList.toggle('sa-app-tab-active', on);
$(id)?.setAttribute('aria-selected', on ? 'true' : 'false');
};
tabActive('sa_tab_chat', state.view === 'chat');
tabActive('sa_tab_cards', state.view === 'cards');
tabActive('sa_tab_settings', state.view === 'settings');
tabActive('sa_tab_train', state.view === 'train');
saveSettings();
if (state.view === 'cards') {
renderCardsList();
} else if (state.view === 'settings') {
setSettingsTab(state.settingsTab || 'behavior');
} else if (state.view === 'train') {
window.SA?.training?.render?.();
} else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
// Back in the chat — bring the model home (Krea may have evicted it).
warmLlm({ force: true });
@@ -8403,6 +8503,10 @@
if ((state.busy || state.generating) && !isContinuationTurn(opts)) {
return;
}
if (isTrainingLocked() && !isContinuationTurn(opts)) {
setStatus('Идёт тренировка — чат заблокирован');
return;
}
const rawInput = ($('sa_input')?.value || '').trim();
const text = (opts.forcedUserText || rawInput).trim();
if (!text) {
@@ -8975,6 +9079,7 @@
refreshImagePreview();
}
bootstrapPersisted();
setChatsPanelOpen(!!state.chatsDrawerOpen);
wireDropZone();
wireSplitter();
registerSendButton();
@@ -8986,6 +9091,7 @@
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
});
$('sa_btn_chats_close')?.addEventListener('click', () => setChatsPanelOpen(false));
$('sa_session_label')?.addEventListener('click', (e) => {
e.stopPropagation();
setChatsPanelOpen(!state.chatsPanelOpen);
@@ -9039,6 +9145,7 @@
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
$('sa_tab_train')?.addEventListener('click', () => setView('train'));
$('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
@@ -9050,14 +9157,6 @@
$('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
$('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
$('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
$('sa_btn_settings')?.addEventListener('click', () => {
if (state.view === 'settings') {
closeSettings();
} else {
openSettings(state.settingsTab || 'behavior');
}
});
$('sa_settings_close')?.addEventListener('click', () => closeSettings());
document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
});
@@ -9348,6 +9447,14 @@
}
}, 30000);
window.SA = window.SA || {};
window.SA.app = {
getState: () => state,
setTrainingLock(on) { state.trainingLock = !!on; },
refreshModels: () => refreshModels(),
setStatus: (msg) => setStatus(msg),
};
window.swarmAssistent = {
setImageFromSrc,
putImageOnBoard,
+3
View File
@@ -17,3 +17,6 @@ window.SA.applyConfigPatchKeys = function (config) {
};
import './app.js';
import { attachTraining } from './training.js';
attachTraining(window.SA);
+531
View File
@@ -0,0 +1,531 @@
/** 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;');
}
export function attachTraining(SA) {
const state = {
ttab: 'dataset',
samples: [],
hfResults: [],
hfSelected: null,
hfCheck: null,
trainWs: null,
polling: null,
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
agentLinked: 0,
};
function setAgentHeardStats(linked) {
const el = $('sa_agent_heard_stats');
if (el) {
el.textContent = `Подключено: ${linked ?? state.agentLinked ?? '—'}`;
}
}
async function loadAgentHeardSettings() {
try {
const data = await SA.request('AssistentGetDatasetAgentSettings', {});
const s = data?.settings || {};
state.agentSettings = {
enabled: s.enabled !== false,
auto_link_on_approve: s.auto_link_on_approve !== false,
heard_quota: s.heard_quota ?? 3,
};
state.agentLinked = data?.linked ?? 0;
if ($('sa_agent_heard_enabled')) $('sa_agent_heard_enabled').checked = state.agentSettings.enabled;
if ($('sa_agent_auto_link')) $('sa_agent_auto_link').checked = state.agentSettings.auto_link_on_approve;
if ($('sa_agent_heard_quota')) $('sa_agent_heard_quota').value = String(state.agentSettings.heard_quota);
setAgentHeardStats(state.agentLinked);
} catch (e) {
console.warn('loadAgentHeardSettings', e);
}
}
async function saveAgentHeardSettings() {
const settings = {
enabled: !!$('sa_agent_heard_enabled')?.checked,
auto_link_on_approve: !!$('sa_agent_auto_link')?.checked,
heard_quota: Math.max(0, Math.min(8, parseInt($('sa_agent_heard_quota')?.value, 10) || 3)),
};
try {
const data = await SA.request('AssistentSaveDatasetAgentSettings', { settings });
state.agentSettings = data?.settings || settings;
setTrainStatus('Настройки «услышанного» сохранены');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function syncAllToAgent() {
setTrainStatus('Подключение к агенту…');
try {
await saveAgentHeardSettings();
const data = await SA.request('AssistentSyncDatasetToAgent', { approved_only: true, relink: false });
state.agentLinked = data?.total_linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus(`Подключено: +${data?.linked_now ?? 0}, всего ${data?.total_linked ?? '—'}`);
await refreshSamples();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
function setTrainStatus(msg) {
const el = $('sa_train_status');
if (el) el.textContent = msg || '';
}
function setTrainingTab(id) {
state.ttab = id || 'dataset';
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
const on = btn.getAttribute('data-ttab') === state.ttab;
btn.classList.toggle('sa-ttab-active', on);
btn.setAttribute('aria-selected', on ? 'true' : 'false');
});
document.querySelectorAll('#sa_training .sa-tpane').forEach((pane) => {
pane.hidden = pane.getAttribute('data-tpane') !== state.ttab;
});
if (state.ttab === 'dataset') {
refreshSamples();
loadAgentHeardSettings();
}
if (state.ttab === 'train') syncModelfileModels();
if (state.ttab === 'models') refreshTrainModels();
}
async function refreshSamples() {
try {
const status = $('sa_train_filter_status')?.value || 'all';
const persona = $('sa_train_filter_persona')?.value || 'all';
const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 });
state.samples = data?.samples || [];
const stats = $('sa_train_stats');
if (stats) stats.textContent = `Одобрено: ${data?.approved ?? '—'} · всего: ${data?.total ?? '—'}`;
const personaSel = $('sa_train_filter_persona');
if (personaSel && $('sa_persona')) {
const cur = personaSel.value || 'all';
personaSel.innerHTML = '<option value="all">Все личности</option>';
for (const opt of $('sa_persona').options) {
const o = document.createElement('option');
o.value = opt.value;
o.textContent = opt.textContent;
personaSel.appendChild(o);
}
personaSel.value = cur;
}
renderSamples();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
function renderSamples() {
const root = $('sa_train_samples');
if (!root) return;
if (!state.samples.length) {
root.innerHTML = '<div class="sa-mem-empty">Нет примеров. Отметь ответы в чате или импортируй датасет.</div>';
return;
}
root.innerHTML = '';
for (const s of state.samples) {
const div = document.createElement('div');
div.className = 'sa-train-sample';
div.dataset.id = s.id;
const msgs = s.messages || [];
const preview = msgs.map((m) => `${m.role}: ${(m.content || '').slice(0, 120)}`).join('\n');
const linked = s.agent_linked ? ' · 🔗 агент' : '';
div.innerHTML = `
<div class="sa-train-sample-head">
<span class="sa-hf-badge sa-hf-badge-${s.status === 'approved' ? 'ok' : s.status === 'rejected' ? 'no' : 'map'}">${escapeHtml(s.status)}</span>
<span>${escapeHtml(s.source)} · ${escapeHtml(s.persona || '—')} · ${escapeHtml(s.pack || '—')}${linked}</span>
<button type="button" class="basic-button" data-approve="1">✓</button>
<button type="button" class="basic-button" data-reject="1">✕</button>
<button type="button" class="basic-button" data-link="1" title="Подключить к агенту">🔗</button>
<button type="button" class="basic-button" data-unlink="1" title="Отключить от агента">⛓</button>
<button type="button" class="basic-button sa-danger-btn" data-del="1">Удалить</button>
</div>
<textarea spellcheck="false">${escapeHtml(preview)}</textarea>`;
root.appendChild(div);
}
}
async function upsertSample(patch) {
await SA.request('AssistentUpsertTrainSample', patch);
await refreshSamples();
}
function renderHfList() {
const root = $('sa_hf_list');
if (!root) return;
root.innerHTML = '';
const showAll = !!$('sa_hf_show_all')?.checked;
for (const r of state.hfResults) {
if (!showAll && r.gate === 'rejected') continue;
const row = document.createElement('div');
row.className = 'sa-hf-row' + (state.hfSelected === r.id ? ' sa-hf-row-active' : '') + (r.gate === 'rejected' ? ' sa-hf-rejected' : '');
row.dataset.id = r.id;
const badge = r.gate === 'ok' ? 'ok' : r.gate === 'mapping' ? 'map' : 'no';
row.innerHTML = `<span class="sa-hf-badge sa-hf-badge-${badge}">${escapeHtml(r.gate)}</span><strong>${escapeHtml(r.id)}</strong><span>${escapeHtml(r.reason || '')}</span>`;
root.appendChild(row);
}
}
async function searchHf() {
const q = ($('sa_hf_search')?.value || '').trim();
setTrainStatus('Поиск…');
try {
const data = await SA.request('AssistentSearchHfDatasets', {
q,
limit: 24,
show_all: !!$('sa_hf_show_all')?.checked,
});
state.hfResults = data?.results || [];
renderHfList();
setTrainStatus(`Найдено: ${state.hfResults.length}`);
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function checkHfLink() {
const link = ($('sa_hf_link')?.value || '').trim();
const status = $('sa_hf_status');
if (!link) return;
if (status) status.textContent = 'Проверяю…';
try {
const data = await SA.request('AssistentCheckHfDataset', { dataset: link });
state.hfCheck = data;
state.hfSelected = data.id;
if (status) {
status.textContent = data.gate === 'rejected'
? `Отклонено: ${data.reason}`
: `${data.gate}: ${data.reason || 'OK'}`;
}
const preview = $('sa_hf_preview');
if (preview) {
preview.hidden = false;
preview.textContent = JSON.stringify(data.sample_rows || data.features || data, null, 2).slice(0, 8000);
}
const importRow = $('sa_hf_import_row');
if (importRow) importRow.hidden = data.gate === 'rejected';
} catch (e) {
if (status) status.textContent = String(e.message || e);
}
}
async function importHf() {
if (!state.hfSelected && !state.hfCheck?.id) {
setTrainStatus('Сначала проверь набор');
return;
}
const id = state.hfSelected || state.hfCheck.id;
const limit = Number($('sa_hf_import_limit')?.value) || 200;
try {
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit });
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
await refreshSamples();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function syncModelfileModels() {
try {
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
const data = await SA.request('AssistentListModels', { baseUrl });
const models = data?.models || [];
for (const selId of ['sa_modelfile_base']) {
const sel = $(selId);
if (!sel) continue;
const cur = sel.value;
sel.innerHTML = '<option value="">—</option>';
for (const m of models) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
sel.appendChild(opt);
}
if (cur) sel.value = cur;
}
const personaSel = $('sa_modelfile_persona');
if (personaSel && $('sa_persona')) {
personaSel.innerHTML = $('sa_persona').innerHTML;
personaSel.value = $('sa_persona').value || 'neutral';
}
} catch (e) { /* ignore */ }
}
async function createModelfile() {
setTrainStatus('Создаю модель…');
try {
const data = await SA.request('AssistentCreateOllamaModel', {
base_url: $('sa_base_url')?.value,
base_model: $('sa_modelfile_base')?.value,
name: $('sa_modelfile_name')?.value,
persona: $('sa_modelfile_persona')?.value,
system: $('sa_modelfile_system')?.value,
shots: Number($('sa_modelfile_shots')?.value) || 8,
num_ctx: Number($('sa_modelfile_num_ctx')?.value) || 16384,
temperature: Number($('sa_modelfile_temp')?.value) || 0.7,
});
setTrainStatus(`Готово: ${data.name}`);
SA.app?.refreshModels?.();
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
function setTrainMode(mode) {
$('sa_train_form_modelfile').hidden = mode !== 'modelfile';
$('sa_train_form_qlora').hidden = mode !== 'qlora';
}
function setTrainingLock(on, text) {
const root = $('swarm_assistent_root');
const banner = $('sa_train_banner');
if (root) root.classList.toggle('sa-root-training-lock', !!on);
if (banner) {
banner.hidden = !on;
const t = $('sa_train_banner_text');
if (t && text) t.textContent = text;
}
SA.app?.setTrainingLock?.(!!on);
}
async function pollTrainJob() {
try {
const data = await SA.request('AssistentGetTrainJob', {});
const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null;
const active = data?.training_active || data?.job?.status === 'running';
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…');
const logEl = $('sa_train_log');
const bar = $('sa_train_progress_fill');
const box = $('sa_train_progress');
if (prog) {
if (box) box.hidden = false;
if (bar && prog.percent != null) bar.style.width = `${prog.percent}%`;
if (logEl && prog.log) logEl.textContent = prog.log;
}
if (!active) {
clearInterval(state.polling);
state.polling = null;
$('sa_btn_qlora_cancel').hidden = true;
}
} catch (e) { /* ignore */ }
}
async function startQlora() {
setTrainStatus('Запуск…');
try {
await SA.request('AssistentStartTrainJob', {
base_url: $('sa_base_url')?.value,
chat_model: $('sa_model')?.value,
base_model: $('sa_qlora_base')?.value,
output_name: $('sa_qlora_name')?.value,
rank: Number($('sa_qlora_rank')?.value) || 16,
alpha: Number($('sa_qlora_alpha')?.value) || 32,
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
epochs: Number($('sa_qlora_epochs')?.value) || 3,
seq_len: Number($('sa_qlora_seq')?.value) || 2048,
four_bit: !!$('sa_qlora_4bit')?.checked,
hf_dataset: ($('sa_qlora_hf_dataset')?.value || '').trim() || undefined,
});
$('sa_btn_qlora_cancel').hidden = false;
setTrainingLock(true, 'Идёт тренировка…');
if (state.polling) clearInterval(state.polling);
state.polling = setInterval(pollTrainJob, 1500);
pollTrainJob();
setTrainStatus('Тренировка запущена');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function cancelQlora() {
try {
await SA.request('AssistentCancelTrainJob', {});
setTrainingLock(false);
setTrainStatus('Отменено');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function refreshTrainModels() {
const root = $('sa_train_models_list');
if (!root) return;
try {
const data = await SA.request('AssistentListModels', { baseUrl: $('sa_base_url')?.value });
const models = data?.models || [];
root.innerHTML = models.length
? models.map((m) => `<div class="sa-hf-row"><strong>${escapeHtml(m)}</strong></div>`).join('')
: '<div class="sa-mem-empty">Нет моделей</div>';
} catch (e) {
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(e.message)}</div>`;
}
}
async function saveRunner() {
try {
await SA.request('AssistentSaveRunnerSettings', {
python: $('sa_runner_python')?.value,
kind: $('sa_runner_kind')?.value,
workdir: $('sa_runner_workdir')?.value,
cmd: $('sa_runner_cmd')?.value,
gguf_script: $('sa_runner_gguf_script')?.value,
});
setTrainStatus('Раннер сохранён');
} catch (e) {
setTrainStatus(String(e.message || e));
}
}
async function loadRunner() {
try {
const data = await SA.request('AssistentGetRunnerSettings', {});
const s = data?.settings || {};
if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python;
if ($('sa_runner_kind') && s.kind) $('sa_runner_kind').value = s.kind;
if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir;
if ($('sa_runner_cmd') && s.cmd) $('sa_runner_cmd').value = s.cmd;
if ($('sa_runner_gguf_script') && s.gguf_script) $('sa_runner_gguf_script').value = s.gguf_script;
} catch (e) { /* ignore */ }
}
function wireTraining() {
if (window.__saTrainingWired) return;
window.__saTrainingWired = true;
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
btn.addEventListener('click', () => setTrainingTab(btn.getAttribute('data-ttab')));
});
$('sa_btn_agent_sync')?.addEventListener('click', syncAllToAgent);
$('sa_agent_heard_enabled')?.addEventListener('change', saveAgentHeardSettings);
$('sa_agent_auto_link')?.addEventListener('change', saveAgentHeardSettings);
$('sa_agent_heard_quota')?.addEventListener('change', saveAgentHeardSettings);
loadAgentHeardSettings();
$('sa_btn_train_from_chats')?.addEventListener('click', async () => {
try {
const data = await SA.request('AssistentBuildDatasetFromChats', {});
setTrainStatus(`Из чатов: +${data.added}`);
await refreshSamples();
} catch (e) { setTrainStatus(String(e.message || e)); }
});
$('sa_btn_train_import_file')?.addEventListener('click', () => $('sa_train_import_file')?.click());
$('sa_train_import_file')?.addEventListener('change', async (e) => {
const file = e.target?.files?.[0];
if (!file) return;
const text = await file.text();
try {
const data = await SA.request('AssistentImportDataset', { format: 'auto', content: text });
setTrainStatus(`Импорт: ${data.imported}`);
await refreshSamples();
} catch (err) { setTrainStatus(String(err.message || err)); }
e.target.value = '';
});
$('sa_btn_train_export')?.addEventListener('click', async () => {
try {
const data = await SA.request('AssistentExportDataset', { status: 'approved' });
if (data.content) {
const blob = new Blob([data.content], { type: 'application/jsonl' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'assistent-dataset.jsonl';
a.click();
}
setTrainStatus(`Экспорт: ${data.count} примеров`);
} catch (e) { setTrainStatus(String(e.message || e)); }
});
$('sa_train_filter_status')?.addEventListener('change', refreshSamples);
$('sa_train_filter_persona')?.addEventListener('change', refreshSamples);
$('sa_train_samples')?.addEventListener('click', async (e) => {
const row = e.target.closest('.sa-train-sample');
if (!row) return;
const id = row.dataset.id;
const sample = state.samples.find((s) => s.id === id);
if (!sample) return;
if (e.target.closest('[data-approve]')) {
await upsertSample({ ...sample, status: 'approved' });
await loadAgentHeardSettings();
} else if (e.target.closest('[data-reject]')) {
await upsertSample({ ...sample, status: 'rejected' });
await loadAgentHeardSettings();
} else if (e.target.closest('[data-link]')) {
try {
const data = await SA.request('AssistentLinkTrainSampleToAgent', { id });
state.agentLinked = data?.linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus('Пример подключён к агенту');
await refreshSamples();
} catch (err) { setTrainStatus(String(err.message || err)); }
} else if (e.target.closest('[data-unlink]')) {
try {
const data = await SA.request('AssistentUnlinkTrainSampleFromAgent', { id });
state.agentLinked = data?.linked ?? state.agentLinked;
setAgentHeardStats(state.agentLinked);
setTrainStatus('Пример отключён от агента');
await refreshSamples();
} catch (err) { setTrainStatus(String(err.message || err)); }
} else if (e.target.closest('[data-del]')) {
if (window.confirm('Удалить пример?')) {
await SA.request('AssistentDeleteTrainSample', { id });
await refreshSamples();
}
}
});
$('sa_btn_hf_search')?.addEventListener('click', searchHf);
$('sa_hf_show_all')?.addEventListener('change', () => { renderHfList(); });
$('sa_hf_list')?.addEventListener('click', async (e) => {
const row = e.target.closest('.sa-hf-row');
if (!row || row.classList.contains('sa-hf-rejected')) return;
state.hfSelected = row.dataset.id;
$('sa_hf_link').value = row.dataset.id;
renderHfList();
await checkHfLink();
});
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
$('sa_btn_hf_import')?.addEventListener('click', importHf);
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
r.addEventListener('change', () => setTrainMode(r.value));
});
$('sa_btn_modelfile_create')?.addEventListener('click', createModelfile);
$('sa_btn_qlora_start')?.addEventListener('click', startQlora);
$('sa_btn_qlora_cancel')?.addEventListener('click', cancelQlora);
$('sa_btn_train_models_refresh')?.addEventListener('click', refreshTrainModels);
$('sa_btn_save_runner')?.addEventListener('click', saveRunner);
loadRunner();
setTrainMode('modelfile');
}
SA.training = {
render() {
wireTraining();
setTrainingTab(state.ttab);
},
async curateFromChat(messages, meta) {
try {
await SA.request('AssistentUpsertTrainSample', {
source: 'chat',
chat_id: meta?.chatId,
persona: meta?.persona,
pack: meta?.pack,
status: meta?.status || 'approved',
messages,
});
return true;
} catch (e) {
console.warn('curateFromChat', e);
return false;
}
},
setTrainingLock,
pollTrainJob,
};
}