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