Ship Assistent 0.12.1: training tab, dataset pipeline, and heard RAG.
Restructure UI with app-level tabs and chat history drawer; add dataset curation, HF import, Modelfile/QLoRA hooks, and link approved samples to the agent immediately via heard vector memory without waiting for fine-tuning. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+1060
-443
File diff suppressed because it is too large
Load Diff
+467
-18
@@ -17,6 +17,137 @@
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.sa-appbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.35rem 0.5rem 0.55rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sa-appbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sa-appbar-title {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.sa-app-tabs {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sa-app-tab {
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.sa-app-tab-active {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-color: color-mix(in srgb, currentColor 42%, transparent);
|
||||
}
|
||||
|
||||
.sa-train-banner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.25rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid color-mix(in srgb, #f5a623 55%, currentColor);
|
||||
background: color-mix(in srgb, #f5a623 16%, transparent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sa-views {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sa-views > .sa-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sa-views > .sa-view[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#sa_view_chat .sa-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sa-chat-workspace {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.sa-chat-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sa-chats-drawer {
|
||||
flex: 0 0 var(--sa-chats-drawer-width, 16rem);
|
||||
width: var(--sa-chats-drawer-width, 16rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-height: 0;
|
||||
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
padding: 0.45rem 0.5rem;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease, flex-basis 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sa-chats-drawer[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sa-chats-drawer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sa-chats-drawer-actions {
|
||||
display: inline-flex;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.sa-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -411,22 +542,21 @@
|
||||
background: color-mix(in srgb, currentColor 10%, transparent);
|
||||
}
|
||||
|
||||
.sa-chats-panel {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
top: calc(100% - 1px);
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
max-width: 26rem;
|
||||
max-height: min(50vh, 22rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.55rem;
|
||||
border-radius: 0 0 0.5rem 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||
background: color-mix(in srgb, #161616 94%, currentColor);
|
||||
box-shadow: 0 12px 28px color-mix(in srgb, #000 40%, transparent);
|
||||
.sa-chats-panel,
|
||||
.sa-chats-drawer {
|
||||
/* drawer lives in .sa-chat-workspace — not a dropdown */
|
||||
position: static;
|
||||
z-index: auto;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-left: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
background: color-mix(in srgb, currentColor 4%, transparent);
|
||||
}
|
||||
|
||||
.sa-chats-panel-head {
|
||||
@@ -1491,7 +1621,13 @@
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sa-subtab {
|
||||
/* app-level tabs replace in-pane subtabs */
|
||||
.sa-subtabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sa-subtab,
|
||||
.sa-app-tab {
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -1503,7 +1639,8 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sa-subtab-active {
|
||||
.sa-subtab-active,
|
||||
.sa-app-tab-active {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
@@ -2016,4 +2153,316 @@
|
||||
width: 98%;
|
||||
max-height: 96%;
|
||||
}
|
||||
.sa-chat-workspace {
|
||||
position: relative;
|
||||
}
|
||||
.sa-chats-drawer:not([hidden]) {
|
||||
position: absolute;
|
||||
z-index: 60;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: min(18rem, 92vw);
|
||||
flex: none;
|
||||
box-shadow: -8px 0 24px color-mix(in srgb, #000 35%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Training tab */
|
||||
.sa-training {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
border-radius: 0.55rem;
|
||||
background: color-mix(in srgb, currentColor 3%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sa-training-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.sa-ttab {
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.sa-ttab-active {
|
||||
opacity: 1;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
.sa-training-panes {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
padding: 0.55rem 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.sa-tpane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sa-train-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sa-train-stats {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.85;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.sa-agent-heard-panel {
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.sa-agent-heard-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.sa-agent-heard-stats {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sa-agent-heard-hint {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.78;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.sa-agent-heard-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.75rem;
|
||||
}
|
||||
|
||||
.sa-agent-heard-controls label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.sa-agent-heard-controls input[type="number"] {
|
||||
width: 2.75rem;
|
||||
}
|
||||
|
||||
.sa-hf-panel {
|
||||
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.55rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.sa-hf-search-row,
|
||||
.sa-hf-link-row,
|
||||
.sa-hf-import-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sa-hf-search,
|
||||
.sa-hf-link {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.sa-hf-status {
|
||||
font-size: 0.76rem;
|
||||
opacity: 0.85;
|
||||
min-height: 1.1rem;
|
||||
}
|
||||
|
||||
.sa-hf-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sa-hf-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.sa-hf-row:hover {
|
||||
background: color-mix(in srgb, currentColor 8%, transparent);
|
||||
}
|
||||
|
||||
.sa-hf-row.sa-hf-row-active {
|
||||
border-color: color-mix(in srgb, currentColor 42%, transparent);
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
|
||||
.sa-hf-row.sa-hf-rejected {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sa-hf-badge {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sa-hf-badge-ok {
|
||||
border-color: color-mix(in srgb, #4caf50 50%, currentColor);
|
||||
}
|
||||
|
||||
.sa-hf-badge-map {
|
||||
border-color: color-mix(in srgb, #f5a623 50%, currentColor);
|
||||
}
|
||||
|
||||
.sa-hf-badge-no {
|
||||
border-color: color-mix(in srgb, #e74c3c 50%, currentColor);
|
||||
}
|
||||
|
||||
.sa-hf-preview {
|
||||
font-size: 0.72rem;
|
||||
max-height: 10rem;
|
||||
overflow: auto;
|
||||
border: 1px solid color-mix(in srgb, currentColor 14%, transparent);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
white-space: pre-wrap;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.sa-train-samples {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 24rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sa-train-sample {
|
||||
border: 1px solid color-mix(in srgb, currentColor 16%, transparent);
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.sa-train-sample-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.sa-train-sample textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 3rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.sa-msg-curate {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.sa-msg-curate button {
|
||||
font-size: 0.72rem !important;
|
||||
padding: 0.1rem 0.4rem !important;
|
||||
}
|
||||
|
||||
.sa-train-modes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sa-train-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
font-size: 0.78rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.sa-train-progress-bar {
|
||||
height: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentColor 12%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sa-train-progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: color-mix(in srgb, #4caf50 70%, currentColor);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.sa-train-log {
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
font-size: 0.72rem;
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0.45rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, #000 22%, transparent);
|
||||
}
|
||||
|
||||
.sa-train-models-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.sa-root-training-lock .sa-composer,
|
||||
.sa-root-training-lock #sa_btn_send,
|
||||
.sa-root-training-lock #sa_btn_build_gen {
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ public partial class SwarmAssistentExtension
|
||||
{
|
||||
AssistentMemory.RetrieveOptions opt = MemoryRetrieveOptions(pid);
|
||||
hits = await Memory.RetrieveAsync(root, retrieveQuery, opt.TopK, embed, Config.PersonaExtendsChain(pid), opt);
|
||||
hits = FilterHeardHitsIfDisabled(hits);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -192,7 +193,7 @@ public partial class SwarmAssistentExtension
|
||||
}
|
||||
}
|
||||
|
||||
string enrichedContext = InjectMemoryHits(contextJson, hits);
|
||||
string enrichedContext = InjectMemoryHits(contextJson, hits, pid);
|
||||
if (!slimDebug)
|
||||
{
|
||||
enrichedContext = EnrichPersonaContext(enrichedContext, pid, packName);
|
||||
@@ -338,24 +339,51 @@ public partial class SwarmAssistentExtension
|
||||
AssistentMemory.RetrieveOptions MemoryRetrieveOptions(string pid)
|
||||
{
|
||||
JObject a = Config.LoadAssistant(pid) ?? new JObject();
|
||||
JObject agent = Config.LoadTrainingAgent();
|
||||
AssistentMemory.RetrieveOptions opt = new()
|
||||
{
|
||||
TopK = a["memory_top_k"]?.Value<int?>() ?? 8,
|
||||
MinScore = a["memory_min_score"]?.Value<float?>() ?? 0.32f,
|
||||
ApplyQuotas = true,
|
||||
};
|
||||
if (a["memory_quotas"] is JObject quotas)
|
||||
Dictionary<string, int> quotas = AssistentMemory.CopyDefaultQuotas();
|
||||
if (a["memory_quotas"] is JObject qOverrides)
|
||||
{
|
||||
Dictionary<string, int> d = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (JProperty p in quotas.Properties())
|
||||
foreach (JProperty p in qOverrides.Properties())
|
||||
{
|
||||
d[p.Name] = p.Value?.Value<int?>() ?? 2;
|
||||
quotas[p.Name] = p.Value?.Value<int?>() ?? 2;
|
||||
}
|
||||
opt.Quotas = d;
|
||||
}
|
||||
if (agent["enabled"]?.Value<bool?>() != false)
|
||||
{
|
||||
quotas["heard"] = agent["heard_quota"]?.Value<int?>() ?? 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
quotas.Remove("heard");
|
||||
}
|
||||
opt.Quotas = quotas;
|
||||
return opt;
|
||||
}
|
||||
|
||||
JArray FilterHeardHitsIfDisabled(JArray hits)
|
||||
{
|
||||
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() != false)
|
||||
{
|
||||
return hits;
|
||||
}
|
||||
JArray filtered = [];
|
||||
foreach (JToken t in hits ?? [])
|
||||
{
|
||||
if (t is JObject ho && string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
filtered.Add(t);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async Task<(string follow, JArray civitai)> RunToolHop(
|
||||
Session session,
|
||||
string root,
|
||||
@@ -413,6 +441,38 @@ public partial class SwarmAssistentExtension
|
||||
+ rows.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||
null);
|
||||
}
|
||||
if (tool == "heard_search")
|
||||
{
|
||||
if (Config.LoadTrainingAgent()["enabled"]?.Value<bool?>() == false)
|
||||
{
|
||||
return ("heard_search disabled in training-agent settings.", null);
|
||||
}
|
||||
string q = patch["memory_query"]?.ToString()?.Trim()
|
||||
?? patch["search_query"]?.ToString()?.Trim()
|
||||
?? ExtractMemoryQuery(patch);
|
||||
if (string.IsNullOrWhiteSpace(q) || !hopDone.Add("heard:" + q))
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
int topK = Config.LoadTrainingAgent()["heard_quota"]?.Value<int?>() ?? 3;
|
||||
JArray rows = await Memory.SearchAsync(root, q, AssistentMemory.HeardKind, topK, embed, chain);
|
||||
JArray examples = [];
|
||||
foreach (JToken t in rows)
|
||||
{
|
||||
if (t is JObject ho)
|
||||
{
|
||||
JObject ex = Memory.BuildHeardExampleFromHit(ho, chain);
|
||||
if (ex is not null)
|
||||
{
|
||||
examples.Add(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
"heard_search — curated dialogue examples the assistant learned (style/reference, not hard rules). Use tone and structure; omit heard_search unless you need more examples.\n```json\n"
|
||||
+ examples.ToString(Newtonsoft.Json.Formatting.None) + "\n```",
|
||||
null);
|
||||
}
|
||||
if (tool == "lookup_tags")
|
||||
{
|
||||
string q = ExtractTagQuery(patch);
|
||||
@@ -599,7 +659,7 @@ public partial class SwarmAssistentExtension
|
||||
return outRows;
|
||||
}
|
||||
|
||||
string InjectMemoryHits(string contextJson, JArray hits, JObject exact = null)
|
||||
string InjectMemoryHits(string contextJson, JArray hits, string personaId = null)
|
||||
{
|
||||
JObject ctx;
|
||||
try
|
||||
@@ -614,7 +674,7 @@ public partial class SwarmAssistentExtension
|
||||
int hitChars = 240;
|
||||
try
|
||||
{
|
||||
string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? Config?.DefaultPersonaId() ?? "neutral";
|
||||
string pidHit = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral";
|
||||
hitChars = Config?.LoadAssistant(pidHit)?["memory_hit_chars"]?.Value<int?>() ?? 240;
|
||||
}
|
||||
catch
|
||||
@@ -623,7 +683,11 @@ public partial class SwarmAssistentExtension
|
||||
}
|
||||
hitChars = Math.Max(80, Math.Min(hitChars, 800));
|
||||
|
||||
string pid = AssistentConfig.SafeId(ctx["persona"]?.ToString()) ?? personaId ?? Config?.DefaultPersonaId() ?? "neutral";
|
||||
IEnumerable<string> chain = Config?.PersonaExtendsChain(pid) ?? [];
|
||||
|
||||
JArray clippedHits = [];
|
||||
JArray heardExamples = [];
|
||||
foreach (JToken t in hits ?? [])
|
||||
{
|
||||
if (t is not JObject ho)
|
||||
@@ -634,6 +698,15 @@ public partial class SwarmAssistentExtension
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(ho["kind"]?.ToString(), AssistentMemory.HeardKind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
JObject ex = Memory?.BuildHeardExampleFromHit(ho, chain);
|
||||
if (ex is not null)
|
||||
{
|
||||
heardExamples.Add(ex);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
JObject copy = (JObject)ho.DeepClone();
|
||||
string text = copy["text"]?.ToString() ?? "";
|
||||
if (text.Length > hitChars)
|
||||
@@ -644,6 +717,14 @@ public partial class SwarmAssistentExtension
|
||||
clippedHits.Add(copy);
|
||||
}
|
||||
ctx["memory_hits"] = clippedHits;
|
||||
if (heardExamples.Count > 0)
|
||||
{
|
||||
ctx["heard_examples"] = heardExamples;
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.Remove("heard_examples");
|
||||
}
|
||||
ctx.Remove("taste_profile");
|
||||
ctx.Remove("enabled_loras"); // alias of selected_loras — do not double-feed
|
||||
try
|
||||
|
||||
@@ -1380,6 +1380,40 @@ public sealed class AssistentConfig
|
||||
}
|
||||
}
|
||||
|
||||
public JObject LoadTrainingRunner()
|
||||
=> TryReadJson(Path.Combine(_overlayRoot, "training-runner.json")) ?? new JObject();
|
||||
|
||||
public void SaveTrainingRunner(JObject settings)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
Directory.CreateDirectory(_overlayRoot);
|
||||
string path = Path.Combine(_overlayRoot, "training-runner.json");
|
||||
JObject merged = DeepMerge(LoadTrainingRunner(), settings ?? new JObject());
|
||||
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
public JObject LoadTrainingAgent()
|
||||
=> TryReadJson(Path.Combine(_overlayRoot, "training-agent.json"))
|
||||
?? new JObject
|
||||
{
|
||||
["enabled"] = true,
|
||||
["auto_link_on_approve"] = true,
|
||||
["heard_quota"] = 3,
|
||||
};
|
||||
|
||||
public void SaveTrainingAgent(JObject settings)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
Directory.CreateDirectory(_overlayRoot);
|
||||
string path = Path.Combine(_overlayRoot, "training-agent.json");
|
||||
JObject merged = DeepMerge(LoadTrainingAgent(), settings ?? new JObject());
|
||||
File.WriteAllText(path, merged.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
public JObject LoadOllamaRoles()
|
||||
{
|
||||
return TryReadJson(Path.Combine(_overlayRoot, "ollama-roles.json"))
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Accounts;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Hugging Face datasets: search, compatibility gate, preview, import.</summary>
|
||||
public partial class SwarmAssistentExtension
|
||||
{
|
||||
static readonly Regex HfRepoIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9._-]{0,95}(/[A-Za-z0-9][A-Za-z0-9._-]{0,95})?$", RegexOptions.Compiled);
|
||||
|
||||
const string HfDatasetsServer = "https://datasets-server.huggingface.co";
|
||||
const string HfHubApi = "https://huggingface.co/api/datasets";
|
||||
|
||||
static string GetHfToken(Session session)
|
||||
=> session?.User?.GetGenericData("huggingface_api", "key")?.Trim();
|
||||
|
||||
static HttpRequestMessage HfRequest(string url, Session session)
|
||||
{
|
||||
HttpRequestMessage req = new(HttpMethod.Get, url);
|
||||
string token = GetHfToken(session);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
/// <summary>Normalize owner/name or HF datasets URL. Returns null when invalid.</summary>
|
||||
public static string NormalizeHfDatasetId(string raw)
|
||||
{
|
||||
string s = (raw ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (s.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || s.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!Uri.TryCreate(s, UriKind.Absolute, out Uri uri))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!string.Equals(uri.Host, "huggingface.co", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string[] parts = uri.AbsolutePath.Trim('/').Split('/');
|
||||
if (parts.Length < 2 || !string.Equals(parts[0], "datasets", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
s = $"{parts[1]}/{parts[2]}";
|
||||
}
|
||||
s = s.Trim().TrimEnd('/');
|
||||
return HfRepoIdRe.IsMatch(s) ? s : null;
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentSearchHfDatasets(Session session, string q = null, int limit = 20, bool show_all = false)
|
||||
{
|
||||
int take = Math.Clamp(limit, 1, 50);
|
||||
string search = (q ?? "").Trim();
|
||||
StringBuilder url = new($"{HfHubApi}?limit={take}&full=true");
|
||||
url.Append("&filter=task_categories:text-generation");
|
||||
url.Append("&filter=modality:text");
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
url.Append("&search=").Append(Uri.EscapeDataString(search));
|
||||
}
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage req = HfRequest(url.ToString(), session);
|
||||
using HttpResponseMessage resp = await HttpClient.SendAsync(req);
|
||||
string body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
return new JObject { ["error"] = $"HF search HTTP {(int)resp.StatusCode}: {Clip(body, 300)}" };
|
||||
}
|
||||
JArray rawList = JArray.Parse(body);
|
||||
JArray results = [];
|
||||
foreach (JToken item in rawList)
|
||||
{
|
||||
if (item is not JObject o)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string id = o["id"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
JObject check = await CheckHfDatasetInternal(session, id, useCache: true);
|
||||
string gate = check["gate"]?.ToString() ?? "rejected";
|
||||
if (!show_all && gate == "rejected")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
results.Add(new JObject
|
||||
{
|
||||
["id"] = id,
|
||||
["title"] = o["id"],
|
||||
["downloads"] = o["downloads"],
|
||||
["gate"] = gate,
|
||||
["reason"] = check["reason"],
|
||||
["schema"] = check["schema"],
|
||||
});
|
||||
}
|
||||
return new JObject { ["success"] = true, ["results"] = results, ["has_hf_token"] = !string.IsNullOrWhiteSpace(GetHfToken(session)) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = $"HF search: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentCheckHfDataset(Session session, string dataset)
|
||||
{
|
||||
string id = NormalizeHfDatasetId(dataset);
|
||||
if (id is null)
|
||||
{
|
||||
return new JObject { ["success"] = false, ["error"] = "Нужен owner/name или ссылка huggingface.co/datasets/…" };
|
||||
}
|
||||
JObject check = await CheckHfDatasetInternal(session, id, useCache: false);
|
||||
check["success"] = check["gate"]?.ToString() != "rejected";
|
||||
check["id"] = id;
|
||||
return check;
|
||||
}
|
||||
|
||||
async Task<JObject> CheckHfDatasetInternal(Session session, string datasetId, bool useCache)
|
||||
{
|
||||
string cacheKey = $"hf:{datasetId}";
|
||||
if (useCache)
|
||||
{
|
||||
JObject cached = Memory.GetKvObject(KvHfDatasetCache)?[cacheKey] as JObject;
|
||||
if (cached is not null && cached["checked_at"]?.Value<long?>() > DateTimeOffset.UtcNow.AddHours(-6).ToUnixTimeMilliseconds())
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
JObject result = new() { ["id"] = datasetId, ["gate"] = "rejected", ["reason"] = "unknown" };
|
||||
try
|
||||
{
|
||||
using HttpRequestMessage validReq = HfRequest($"{HfDatasetsServer}/is-valid?dataset={Uri.EscapeDataString(datasetId)}", session);
|
||||
using HttpResponseMessage validResp = await HttpClient.SendAsync(validReq);
|
||||
string validBody = await validResp.Content.ReadAsStringAsync();
|
||||
if (!validResp.IsSuccessStatusCode)
|
||||
{
|
||||
result["reason"] = $"is-valid HTTP {(int)validResp.StatusCode}";
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
JObject valid = JObject.Parse(validBody);
|
||||
bool viewer = valid["viewer"]?.Value<bool?>() == true;
|
||||
bool preview = valid["preview"]?.Value<bool?>() == true;
|
||||
if (!viewer && !preview)
|
||||
{
|
||||
result["reason"] = "Набор не читается через datasets (viewer/preview = false)";
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
using HttpRequestMessage splitReq = HfRequest($"{HfDatasetsServer}/splits?dataset={Uri.EscapeDataString(datasetId)}", session);
|
||||
using HttpResponseMessage splitResp = await HttpClient.SendAsync(splitReq);
|
||||
string splitBody = await splitResp.Content.ReadAsStringAsync();
|
||||
if (!splitResp.IsSuccessStatusCode)
|
||||
{
|
||||
result["reason"] = $"splits HTTP {(int)splitResp.StatusCode}";
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
JArray splits = JObject.Parse(splitBody)["splits"] as JArray ?? [];
|
||||
if (splits.Count == 0)
|
||||
{
|
||||
result["reason"] = "Нет splits";
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
JObject first = splits[0] as JObject;
|
||||
string config = first?["config"]?.ToString() ?? "default";
|
||||
string split = first?["split"]?.ToString() ?? "train";
|
||||
using HttpRequestMessage rowsReq = HfRequest($"{HfDatasetsServer}/first-rows?dataset={Uri.EscapeDataString(datasetId)}&config={Uri.EscapeDataString(config)}&split={Uri.EscapeDataString(split)}", session);
|
||||
using HttpResponseMessage rowsResp = await HttpClient.SendAsync(rowsReq);
|
||||
string rowsBody = await rowsResp.Content.ReadAsStringAsync();
|
||||
if (!rowsResp.IsSuccessStatusCode)
|
||||
{
|
||||
result["reason"] = $"first-rows HTTP {(int)rowsResp.StatusCode}: {Clip(rowsBody, 200)}";
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
JObject rowsData = JObject.Parse(rowsBody);
|
||||
JObject features = rowsData["features"] as JObject;
|
||||
(string gate, string reason, JObject schema) = ClassifyHfFeatures(features);
|
||||
result["gate"] = gate;
|
||||
result["reason"] = reason;
|
||||
result["schema"] = schema;
|
||||
result["config"] = config;
|
||||
result["split"] = split;
|
||||
result["features"] = features;
|
||||
result["sample_rows"] = rowsData["rows"];
|
||||
result["runner_only"] = HasHugeSizeTag(datasetId);
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result["reason"] = ex.Message;
|
||||
return CacheHfCheck(cacheKey, result);
|
||||
}
|
||||
}
|
||||
|
||||
static bool HasHugeSizeTag(string datasetId) => false;
|
||||
|
||||
JObject CacheHfCheck(string cacheKey, JObject result)
|
||||
{
|
||||
result["checked_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
try
|
||||
{
|
||||
JObject bag = Memory.GetKvObject(KvHfDatasetCache) ?? new JObject();
|
||||
bag[cacheKey] = result;
|
||||
Memory.SetKvObject(KvHfDatasetCache, bag);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"CacheHfCheck: {ex.Message}");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static (string gate, string reason, JObject schema) ClassifyHfFeatures(JObject features)
|
||||
{
|
||||
if (features is null || !features.Properties().Any())
|
||||
{
|
||||
return ("rejected", "Нет колонок (features пуст)", null);
|
||||
}
|
||||
HashSet<string> names = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (JProperty p in features.Properties())
|
||||
{
|
||||
names.Add(p.Name);
|
||||
JToken dtype = p.Value?["dtype"] ?? p.Value?["type"];
|
||||
string dt = dtype?.ToString() ?? "";
|
||||
if (dt.Contains("image", StringComparison.OrdinalIgnoreCase)
|
||||
|| dt.Contains("audio", StringComparison.OrdinalIgnoreCase)
|
||||
|| dt.Contains("video", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ("rejected", $"Мультимодальная колонка {p.Name} ({dt})", null);
|
||||
}
|
||||
}
|
||||
if (names.Contains("chosen") && names.Contains("rejected"))
|
||||
{
|
||||
return ("rejected", "DPO-набор (chosen/rejected) — не для SFT", null);
|
||||
}
|
||||
if (names.Count == 1 && names.Contains("text"))
|
||||
{
|
||||
return ("rejected", "Предобучение (одна колонка text), не диалоги", null);
|
||||
}
|
||||
if (names.Contains("messages"))
|
||||
{
|
||||
return ("ok", "OpenAI messages", new JObject { ["kind"] = "messages" });
|
||||
}
|
||||
if (names.Contains("conversations"))
|
||||
{
|
||||
return ("ok", "ShareGPT conversations", new JObject { ["kind"] = "conversations" });
|
||||
}
|
||||
if (names.Contains("instruction") && names.Contains("output"))
|
||||
{
|
||||
return ("ok", "Alpaca instruction/output", new JObject { ["kind"] = "alpaca" });
|
||||
}
|
||||
if (names.Contains("prompt") && (names.Contains("response") || names.Contains("completion") || names.Contains("answer")))
|
||||
{
|
||||
string respCol = names.Contains("response") ? "response" : names.Contains("completion") ? "completion" : "answer";
|
||||
return ("ok", "prompt/response", new JObject { ["kind"] = "prompt_response", ["response_col"] = respCol });
|
||||
}
|
||||
if (names.Contains("question") && names.Contains("answer"))
|
||||
{
|
||||
return ("ok", "question/answer", new JObject { ["kind"] = "qa" });
|
||||
}
|
||||
List<string> stringCols = [];
|
||||
foreach (JProperty p in features.Properties())
|
||||
{
|
||||
JToken dtype = p.Value?["dtype"] ?? p.Value?["type"];
|
||||
string dt = dtype?.ToString() ?? "";
|
||||
if (dt.Contains("string", StringComparison.OrdinalIgnoreCase) || dt == "value")
|
||||
{
|
||||
stringCols.Add(p.Name);
|
||||
}
|
||||
}
|
||||
if (stringCols.Count >= 2)
|
||||
{
|
||||
return ("mapping", "Нужен ручной маппинг колонок", new JObject
|
||||
{
|
||||
["kind"] = "custom",
|
||||
["columns"] = new JArray(stringCols),
|
||||
});
|
||||
}
|
||||
return ("rejected", "Схема не подходит для SFT", null);
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentPreviewHfDataset(Session session, string dataset, string config = null, string split = null)
|
||||
{
|
||||
string id = NormalizeHfDatasetId(dataset);
|
||||
if (id is null)
|
||||
{
|
||||
return new JObject { ["error"] = "invalid dataset id" };
|
||||
}
|
||||
JObject check = await CheckHfDatasetInternal(session, id, useCache: true);
|
||||
if (check["gate"]?.ToString() == "rejected")
|
||||
{
|
||||
return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected", ["check"] = check };
|
||||
}
|
||||
return new JObject { ["success"] = true, ["check"] = check };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentImportHfDataset(Session session, JObject raw)
|
||||
{
|
||||
string dataset = raw?["dataset"]?.ToString();
|
||||
int limit = raw?["limit"]?.Value<int?>() ?? 200;
|
||||
JObject mapping = raw?["mapping"] as JObject;
|
||||
string id = NormalizeHfDatasetId(dataset);
|
||||
if (id is null)
|
||||
{
|
||||
return new JObject { ["error"] = "invalid dataset id" };
|
||||
}
|
||||
JObject check = await CheckHfDatasetInternal(session, id, useCache: true);
|
||||
string gate = check["gate"]?.ToString();
|
||||
if (gate == "rejected")
|
||||
{
|
||||
return new JObject { ["error"] = check["reason"]?.ToString() ?? "rejected" };
|
||||
}
|
||||
if (gate == "mapping" && (mapping is null || mapping.Count == 0))
|
||||
{
|
||||
return new JObject { ["error"] = "Нужен маппинг колонок", ["check"] = check };
|
||||
}
|
||||
int take = Math.Clamp(limit, 1, 5000);
|
||||
JArray rows = [];
|
||||
string config = check["config"]?.ToString() ?? "default";
|
||||
string split = check["split"]?.ToString() ?? "train";
|
||||
int offset = 0;
|
||||
while (rows.Count < take)
|
||||
{
|
||||
int chunk = Math.Min(100, take - rows.Count);
|
||||
using HttpRequestMessage rowsReq = HfRequest($"{HfDatasetsServer}/rows?dataset={Uri.EscapeDataString(id)}&config={Uri.EscapeDataString(config)}&split={Uri.EscapeDataString(split)}&offset={offset}&length={chunk}", session);
|
||||
using HttpResponseMessage rowsResp = await HttpClient.SendAsync(rowsReq);
|
||||
string rowsBody = await rowsResp.Content.ReadAsStringAsync();
|
||||
if (!rowsResp.IsSuccessStatusCode)
|
||||
{
|
||||
break;
|
||||
}
|
||||
JObject parsed = JObject.Parse(rowsBody);
|
||||
JArray batch = parsed["rows"] as JArray ?? [];
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
foreach (JToken t in batch)
|
||||
{
|
||||
rows.Add(t);
|
||||
}
|
||||
offset += batch.Count;
|
||||
if (batch.Count < chunk)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
rows = check["sample_rows"] as JArray ?? [];
|
||||
}
|
||||
int imported = 0;
|
||||
foreach (JToken rowTok in rows.Take(take))
|
||||
{
|
||||
if (rowTok is not JObject row)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
JObject rowData = row["row"] as JObject ?? row;
|
||||
JArray messages = ConvertHfRowToMessages(rowData, check["schema"] as JObject, mapping);
|
||||
if (messages is null || messages.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Memory.UpsertTrainSample(new JObject
|
||||
{
|
||||
["source"] = "hf",
|
||||
["hf_repo"] = id,
|
||||
["messages"] = messages,
|
||||
["status"] = "draft",
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
if (imported == 0 && check["runner_only"]?.Value<bool?>() == true)
|
||||
{
|
||||
return new JObject { ["success"] = true, ["imported"] = 0, ["runner_only"] = true, ["id"] = id, ["note"] = "Большой набор — используй HF id в QLoRA-раннере" };
|
||||
}
|
||||
return new JObject { ["success"] = true, ["imported"] = imported, ["id"] = id };
|
||||
}
|
||||
|
||||
static JArray ConvertHfRowToMessages(JObject row, JObject schema, JObject mapping)
|
||||
{
|
||||
string kind = schema?["kind"]?.ToString() ?? mapping?["kind"]?.ToString();
|
||||
if (kind == "messages" && row["messages"] is JArray msgs)
|
||||
{
|
||||
return NormalizeMessagesArray(msgs);
|
||||
}
|
||||
if (kind == "conversations" && row["conversations"] is JArray conv)
|
||||
{
|
||||
JArray outArr = [];
|
||||
foreach (JToken c in conv)
|
||||
{
|
||||
if (c is not JObject co)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string from = co["from"]?.ToString() ?? "";
|
||||
string val = co["value"]?.ToString() ?? "";
|
||||
string role = from is "human" or "user" ? "user" : from is "gpt" or "assistant" or "chatgpt" ? "assistant" : "user";
|
||||
outArr.Add(new JObject { ["role"] = role, ["content"] = val });
|
||||
}
|
||||
return outArr.Count > 0 ? outArr : null;
|
||||
}
|
||||
if (kind == "alpaca")
|
||||
{
|
||||
string instr = row["instruction"]?.ToString() ?? "";
|
||||
string inp = row["input"]?.ToString() ?? "";
|
||||
string output = row["output"]?.ToString() ?? "";
|
||||
string user = string.IsNullOrWhiteSpace(inp) ? instr : $"{instr}\n{inp}";
|
||||
return new JArray
|
||||
{
|
||||
new JObject { ["role"] = "user", ["content"] = user },
|
||||
new JObject { ["role"] = "assistant", ["content"] = output },
|
||||
};
|
||||
}
|
||||
if (kind == "prompt_response")
|
||||
{
|
||||
string respCol = schema?["response_col"]?.ToString() ?? "response";
|
||||
return new JArray
|
||||
{
|
||||
new JObject { ["role"] = "user", ["content"] = row["prompt"]?.ToString() ?? "" },
|
||||
new JObject { ["role"] = "assistant", ["content"] = row[respCol]?.ToString() ?? "" },
|
||||
};
|
||||
}
|
||||
if (kind == "qa")
|
||||
{
|
||||
return new JArray
|
||||
{
|
||||
new JObject { ["role"] = "user", ["content"] = row["question"]?.ToString() ?? "" },
|
||||
new JObject { ["role"] = "assistant", ["content"] = row["answer"]?.ToString() ?? "" },
|
||||
};
|
||||
}
|
||||
if (kind == "custom" && mapping is not null)
|
||||
{
|
||||
string userCol = mapping["user_col"]?.ToString();
|
||||
string asstCol = mapping["assistant_col"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(userCol) && !string.IsNullOrWhiteSpace(asstCol))
|
||||
{
|
||||
return new JArray
|
||||
{
|
||||
new JObject { ["role"] = "user", ["content"] = row[userCol]?.ToString() ?? "" },
|
||||
new JObject { ["role"] = "assistant", ["content"] = row[asstCol]?.ToString() ?? "" },
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static JArray NormalizeMessagesArray(JArray msgs)
|
||||
{
|
||||
JArray outArr = [];
|
||||
foreach (JToken m in msgs)
|
||||
{
|
||||
if (m is not JObject mo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string role = mo["role"]?.ToString() ?? "user";
|
||||
string content = mo["content"]?.ToString() ?? mo["text"]?.ToString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
outArr.Add(new JObject { ["role"] = role, ["content"] = content });
|
||||
}
|
||||
return outArr.Count > 0 ? outArr : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Train samples linked to the agent as retrievable "heard" dialogue examples.</summary>
|
||||
public sealed partial class AssistentMemory
|
||||
{
|
||||
public const string HeardKind = "heard";
|
||||
public const string HeardSource = "train";
|
||||
|
||||
public static string FormatHeardEmbedText(JArray messages, string pack, string persona)
|
||||
{
|
||||
StringBuilder sb = new();
|
||||
if (!string.IsNullOrWhiteSpace(pack))
|
||||
{
|
||||
sb.AppendLine($"pack: {pack.Trim()}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(persona))
|
||||
{
|
||||
sb.AppendLine($"persona: {persona.Trim()}");
|
||||
}
|
||||
foreach (JToken t in messages ?? [])
|
||||
{
|
||||
if (t is not JObject m)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string role = m["role"]?.ToString() ?? "user";
|
||||
string content = m["content"]?.ToString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
sb.AppendLine($"{role}: {content.Trim()}");
|
||||
}
|
||||
return sb.ToString().Trim();
|
||||
}
|
||||
|
||||
public void SetTrainSampleAgentLinked(string id, bool linked)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
if (!HasColumn("train_samples", "agent_linked"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "UPDATE train_samples SET agent_linked = $v, updated_at = $u WHERE id = $id";
|
||||
cmd.Parameters.AddWithValue("$v", linked ? 1 : 0);
|
||||
cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
cmd.Parameters.AddWithValue("$id", id.Trim());
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
|
||||
public int CountAgentLinkedTrainSamples()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
if (!HasColumn("train_samples", "agent_linked"))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM train_samples WHERE agent_linked = 1";
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> LinkTrainSampleToAgentAsync(string baseUrl, JObject sample, string embedModel)
|
||||
{
|
||||
if (sample is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string id = sample["id"]?.ToString()?.Trim();
|
||||
JArray messages = sample["messages"] as JArray ?? [];
|
||||
if (string.IsNullOrWhiteSpace(id) || messages.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string status = sample["status"]?.ToString() ?? "draft";
|
||||
if (!string.Equals(status, "approved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
string persona = NormalizePersona(sample["persona"]?.ToString());
|
||||
string pack = sample["pack"]?.ToString()?.Trim() ?? "";
|
||||
string text = FormatHeardEmbedText(messages, pack, persona);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
JObject meta = new()
|
||||
{
|
||||
["train_sample_id"] = id,
|
||||
["pack"] = pack,
|
||||
["messages"] = messages,
|
||||
["source_type"] = sample["source"]?.ToString() ?? "manual",
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(sample["chat_id"]?.ToString()))
|
||||
{
|
||||
meta["chat_id"] = sample["chat_id"]?.ToString();
|
||||
}
|
||||
float[] vec = await EmbedAsync(baseUrl, embedModel, text);
|
||||
Upsert(HeardKind, id, text, HeardSource, meta, vec, persona);
|
||||
SetTrainSampleAgentLinked(id, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void UnlinkTrainSampleFromAgent(JObject sample)
|
||||
{
|
||||
if (sample is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string id = sample["id"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
string persona = NormalizePersona(sample["persona"]?.ToString());
|
||||
Forget(HeardKind, id, HeardSource, persona);
|
||||
if (persona != SharedPersona)
|
||||
{
|
||||
Forget(HeardKind, id, HeardSource, SharedPersona);
|
||||
}
|
||||
SetTrainSampleAgentLinked(id, false);
|
||||
}
|
||||
|
||||
public JObject BuildHeardExampleFromHit(JObject hit, IEnumerable<string> personaChain = null)
|
||||
{
|
||||
if (hit is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
string key = hit["key"]?.ToString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
JObject row = Get(HeardKind, key, personaChain);
|
||||
JObject ex = new()
|
||||
{
|
||||
["id"] = key,
|
||||
["score"] = hit["score"],
|
||||
["persona"] = hit["persona"],
|
||||
["source"] = hit["source"],
|
||||
};
|
||||
JObject meta = null;
|
||||
try
|
||||
{
|
||||
string metaRaw = row?["meta_json"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(metaRaw))
|
||||
{
|
||||
meta = JObject.Parse(metaRaw);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
if (meta?["pack"] is not null)
|
||||
{
|
||||
ex["pack"] = meta["pack"];
|
||||
}
|
||||
if (meta?["messages"] is JArray msgs && msgs.Count > 0)
|
||||
{
|
||||
ex["messages"] = msgs;
|
||||
}
|
||||
else
|
||||
{
|
||||
ex["text"] = hit["text"]?.ToString() ?? row?["text"]?.ToString() ?? "";
|
||||
}
|
||||
return ex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Training datasets, samples, and job metadata in assistent.sqlite.</summary>
|
||||
public sealed partial class AssistentMemory
|
||||
{
|
||||
public const string KvHfDatasetCache = "hf_dataset_cache";
|
||||
|
||||
void EnsureTrainingSchema()
|
||||
{
|
||||
Exec(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS train_datasets (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
meta_json TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS train_samples (
|
||||
id TEXT PRIMARY KEY,
|
||||
dataset_id TEXT NOT NULL DEFAULT 'default',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
chat_id TEXT,
|
||||
persona TEXT,
|
||||
pack TEXT,
|
||||
hf_repo TEXT,
|
||||
messages_json TEXT NOT NULL DEFAULT '[]',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(dataset_id) REFERENCES train_datasets(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_train_samples_status ON train_samples(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_train_samples_dataset ON train_samples(dataset_id);
|
||||
CREATE TABLE IF NOT EXISTS train_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
config_json TEXT,
|
||||
base_model TEXT,
|
||||
output_name TEXT,
|
||||
log_path TEXT,
|
||||
progress_json TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
finished_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_train_jobs_status ON train_jobs(status);
|
||||
""");
|
||||
if (!HasColumn("train_samples", "agent_linked"))
|
||||
{
|
||||
Exec("ALTER TABLE train_samples ADD COLUMN agent_linked INTEGER NOT NULL DEFAULT 0");
|
||||
}
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "INSERT OR IGNORE INTO train_datasets(id, title, created_at, updated_at) VALUES('default', 'Default', $u, $u)";
|
||||
cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public List<JObject> ListTrainSamples(string status = null, string persona = null, string datasetId = null, int limit = 200)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
int take = Math.Clamp(limit, 1, 2000);
|
||||
List<string> where = [];
|
||||
if (!string.IsNullOrWhiteSpace(status) && !string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
where.Add("status = $status");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(persona) && !string.Equals(persona, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
where.Add("persona = $persona");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(datasetId))
|
||||
{
|
||||
where.Add("dataset_id = $ds");
|
||||
}
|
||||
bool hasAgentLinked = HasColumn("train_samples", "agent_linked");
|
||||
string sql = hasAgentLinked
|
||||
? "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at, agent_linked FROM train_samples"
|
||||
: "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at FROM train_samples";
|
||||
if (where.Count > 0)
|
||||
{
|
||||
sql += " WHERE " + string.Join(" AND ", where);
|
||||
}
|
||||
sql += " ORDER BY updated_at DESC LIMIT $lim";
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
if (where.Any(w => w.Contains("$status")))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("$status", status.Trim());
|
||||
}
|
||||
if (where.Any(w => w.Contains("$persona")))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("$persona", persona.Trim());
|
||||
}
|
||||
if (where.Any(w => w.Contains("$ds")))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("$ds", datasetId.Trim());
|
||||
}
|
||||
cmd.Parameters.AddWithValue("$lim", take);
|
||||
List<JObject> list = [];
|
||||
using SqliteDataReader r = cmd.ExecuteReader();
|
||||
while (r.Read())
|
||||
{
|
||||
list.Add(ReadTrainSampleRow(r));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
static JObject ReadTrainSampleRow(SqliteDataReader r)
|
||||
{
|
||||
JArray messages = [];
|
||||
try
|
||||
{
|
||||
messages = JArray.Parse(r.GetString(7));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
bool hasAgentLinked = r.FieldCount > 11;
|
||||
return new JObject
|
||||
{
|
||||
["id"] = r.GetString(0),
|
||||
["dataset_id"] = r.GetString(1),
|
||||
["source"] = r.GetString(2),
|
||||
["chat_id"] = r.IsDBNull(3) ? null : r.GetString(3),
|
||||
["persona"] = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
["pack"] = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
["hf_repo"] = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
["messages"] = messages,
|
||||
["status"] = r.GetString(8),
|
||||
["createdAt"] = r.GetInt64(9),
|
||||
["updatedAt"] = r.GetInt64(10),
|
||||
["agent_linked"] = hasAgentLinked && !r.IsDBNull(11) && r.GetInt64(11) != 0,
|
||||
};
|
||||
}
|
||||
|
||||
public JObject GetTrainSample(string id)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
bool hasAgentLinked = HasColumn("train_samples", "agent_linked");
|
||||
string sql = hasAgentLinked
|
||||
? "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at, agent_linked FROM train_samples WHERE id = $id LIMIT 1"
|
||||
: "SELECT id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at FROM train_samples WHERE id = $id LIMIT 1";
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
cmd.Parameters.AddWithValue("$id", id.Trim());
|
||||
using SqliteDataReader r = cmd.ExecuteReader();
|
||||
return r.Read() ? ReadTrainSampleRow(r) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public JObject UpsertTrainSample(JObject sample)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string id = sample["id"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = $"ts_{now}_{Guid.NewGuid():N}"[..24];
|
||||
}
|
||||
string datasetId = sample["dataset_id"]?.ToString()?.Trim() ?? "default";
|
||||
JArray messages = sample["messages"] as JArray ?? [];
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO train_samples(id, dataset_id, source, chat_id, persona, pack, hf_repo, messages_json, status, created_at, updated_at)
|
||||
VALUES($id, $ds, $src, $chat, $persona, $pack, $hf, $msg, $status, $c, $u)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
dataset_id = excluded.dataset_id,
|
||||
source = excluded.source,
|
||||
chat_id = excluded.chat_id,
|
||||
persona = excluded.persona,
|
||||
pack = excluded.pack,
|
||||
hf_repo = excluded.hf_repo,
|
||||
messages_json = excluded.messages_json,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$ds", datasetId);
|
||||
cmd.Parameters.AddWithValue("$src", sample["source"]?.ToString() ?? "manual");
|
||||
cmd.Parameters.AddWithValue("$chat", (object)sample["chat_id"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$persona", (object)sample["persona"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$pack", (object)sample["pack"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$hf", (object)sample["hf_repo"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$msg", messages.ToString(Newtonsoft.Json.Formatting.None));
|
||||
cmd.Parameters.AddWithValue("$status", sample["status"]?.ToString() ?? "draft");
|
||||
long created = sample["createdAt"]?.Value<long?>() ?? now;
|
||||
cmd.Parameters.AddWithValue("$c", created);
|
||||
cmd.Parameters.AddWithValue("$u", now);
|
||||
cmd.ExecuteNonQuery();
|
||||
return new JObject { ["id"] = id, ["updatedAt"] = now };
|
||||
}
|
||||
}
|
||||
|
||||
public bool DeleteTrainSample(string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM train_samples WHERE id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", id ?? "");
|
||||
return cmd.ExecuteNonQuery() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
public int CountTrainSamples(string status = null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
if (string.IsNullOrWhiteSpace(status) || string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM train_samples";
|
||||
}
|
||||
else
|
||||
{
|
||||
cmd.CommandText = "SELECT COUNT(*) FROM train_samples WHERE status = $s";
|
||||
cmd.Parameters.AddWithValue("$s", status.Trim());
|
||||
}
|
||||
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||
}
|
||||
}
|
||||
|
||||
public JObject SaveTrainJob(JObject job)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string id = job["id"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = $"tj_{now}_{Guid.NewGuid():N}"[..24];
|
||||
}
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO train_jobs(id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at)
|
||||
VALUES($id, $kind, $status, $cfg, $base, $out, $log, $prog, $c, $u, $f)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
config_json = excluded.config_json,
|
||||
log_path = excluded.log_path,
|
||||
progress_json = excluded.progress_json,
|
||||
updated_at = excluded.updated_at,
|
||||
finished_at = excluded.finished_at
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$id", id);
|
||||
cmd.Parameters.AddWithValue("$kind", job["kind"]?.ToString() ?? "qlora");
|
||||
cmd.Parameters.AddWithValue("$status", job["status"]?.ToString() ?? "pending");
|
||||
cmd.Parameters.AddWithValue("$cfg", job["config"]?.ToString(Newtonsoft.Json.Formatting.None) ?? job["config_json"]?.ToString() ?? "{}");
|
||||
cmd.Parameters.AddWithValue("$base", (object)job["base_model"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$out", (object)job["output_name"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$log", (object)job["log_path"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$prog", (object)job["progress"]?.ToString(Newtonsoft.Json.Formatting.None) ?? job["progress_json"]?.ToString() ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("$c", job["created_at"]?.Value<long?>() ?? job["createdAt"]?.Value<long?>() ?? now);
|
||||
cmd.Parameters.AddWithValue("$u", now);
|
||||
cmd.Parameters.AddWithValue("$f", (object)(job["finished_at"]?.Value<long?>() ?? job["finishedAt"]?.Value<long?>()) ?? DBNull.Value);
|
||||
cmd.ExecuteNonQuery();
|
||||
return new JObject { ["id"] = id };
|
||||
}
|
||||
}
|
||||
|
||||
public JObject GetTrainJob(string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at FROM train_jobs WHERE id = $id";
|
||||
cmd.Parameters.AddWithValue("$id", id ?? "");
|
||||
using SqliteDataReader r = cmd.ExecuteReader();
|
||||
if (!r.Read())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["id"] = r.GetString(0),
|
||||
["kind"] = r.GetString(1),
|
||||
["status"] = r.GetString(2),
|
||||
["config_json"] = r.IsDBNull(3) ? null : r.GetString(3),
|
||||
["base_model"] = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
["output_name"] = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
["log_path"] = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
["progress_json"] = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
["created_at"] = r.GetInt64(8),
|
||||
["updated_at"] = r.GetInt64(9),
|
||||
["finished_at"] = r.IsDBNull(10) ? null : r.GetInt64(10),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public JObject GetActiveTrainJob()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT id, kind, status, config_json, base_model, output_name, log_path, progress_json, created_at, updated_at, finished_at FROM train_jobs WHERE status IN ('pending','running') ORDER BY updated_at DESC LIMIT 1";
|
||||
using SqliteDataReader r = cmd.ExecuteReader();
|
||||
if (!r.Read())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["id"] = r.GetString(0),
|
||||
["kind"] = r.GetString(1),
|
||||
["status"] = r.GetString(2),
|
||||
["config_json"] = r.IsDBNull(3) ? null : r.GetString(3),
|
||||
["base_model"] = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
["output_name"] = r.IsDBNull(5) ? null : r.GetString(5),
|
||||
["log_path"] = r.IsDBNull(6) ? null : r.GetString(6),
|
||||
["progress_json"] = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
["created_at"] = r.GetInt64(8),
|
||||
["updated_at"] = r.GetInt64(9),
|
||||
["finished_at"] = r.IsDBNull(10) ? null : r.GetInt64(10),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -37,8 +37,12 @@ public sealed partial class AssistentMemory : IDisposable
|
||||
["note"] = 4,
|
||||
["model"] = 2,
|
||||
["aspect"] = 1,
|
||||
["heard"] = 3,
|
||||
};
|
||||
|
||||
public static Dictionary<string, int> CopyDefaultQuotas()
|
||||
=> new(DefaultQuotas, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
readonly string _dataRoot;
|
||||
readonly string _dbPath;
|
||||
readonly HttpClient _http;
|
||||
@@ -139,6 +143,14 @@ public sealed partial class AssistentMemory : IDisposable
|
||||
Logs.Debug($"AssistentMemory store schema: {ex.Message}");
|
||||
}
|
||||
try
|
||||
{
|
||||
EnsureTrainingSchema();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentMemory training schema: {ex.Message}");
|
||||
}
|
||||
try
|
||||
{
|
||||
EnsureUserPrefsSchema();
|
||||
}
|
||||
@@ -857,7 +869,7 @@ public sealed partial class AssistentMemory : IDisposable
|
||||
{
|
||||
EnsureOpen();
|
||||
using SqliteCommand cmd = _conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT kind, key, text, source, persona, updated FROM memories WHERE kind = $kind AND key = $key";
|
||||
cmd.CommandText = "SELECT kind, key, text, source, persona, updated, meta_json FROM memories WHERE kind = $kind AND key = $key";
|
||||
cmd.Parameters.AddWithValue("$kind", kind);
|
||||
cmd.Parameters.AddWithValue("$key", key);
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
@@ -885,6 +897,7 @@ public sealed partial class AssistentMemory : IDisposable
|
||||
["scope"] = shared ? "shared" : "personal",
|
||||
["persona"] = shared ? "shared" : persona,
|
||||
["updated"] = reader.IsDBNull(5) ? 0 : reader.GetInt64(5),
|
||||
["meta_json"] = reader.IsDBNull(6) ? null : reader.GetString(6),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,7 @@ public partial class SwarmAssistentExtension
|
||||
|| s.Equals("persona_read", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.Equals("memory_get", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.Equals("memory_search", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.Equals("heard_search", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.Equals("lookup_tags", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.Equals("list_inventory", StringComparison.OrdinalIgnoreCase)
|
||||
|| s.Equals("search_civitai", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -277,6 +278,11 @@ public partial class SwarmAssistentExtension
|
||||
{
|
||||
return "memory_search";
|
||||
}
|
||||
if ((ActionsContain(patch, "heard_search") || string.Equals(patch["heard_query"]?.ToString(), "1", StringComparison.Ordinal))
|
||||
&& !Skip("heard_search"))
|
||||
{
|
||||
return "heard_search";
|
||||
}
|
||||
if ((ActionsContain(patch, "lookup_tags") || !string.IsNullOrWhiteSpace(patch["tag_query"]?.ToString()))
|
||||
&& !Skip("lookup_tags"))
|
||||
{
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ public partial class SwarmAssistentExtension
|
||||
static readonly string[] UiStateKeys =
|
||||
[
|
||||
"pack", "persona", "auto_vision", "auto_apply", "auto_generate", "auto_critique",
|
||||
"auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab", "park_llm",
|
||||
"auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab", "park_llm", "chats_drawer",
|
||||
];
|
||||
|
||||
static string SafeChatId(string id)
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Accounts;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Link training dataset samples to the live agent as retrievable "heard" examples.</summary>
|
||||
public partial class SwarmAssistentExtension
|
||||
{
|
||||
static string MemoryEmbedForTraining(string personaId = null)
|
||||
{
|
||||
string pid = AssistentConfig.SafeId(personaId) ?? Config.DefaultPersonaId();
|
||||
return Config.LoadSettings()["embed_model"]?.ToString()
|
||||
?? Config.LoadAssistant(pid)["embed_model"]?.ToString()
|
||||
?? "nomic-embed-text";
|
||||
}
|
||||
|
||||
static string MemoryBaseForTraining(JObject raw = null)
|
||||
=> MemoryBaseUrl(raw?["base_url"]?.ToString());
|
||||
|
||||
public async Task<JObject> AssistentGetDatasetAgentSettings(Session session)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
JObject settings = Config.LoadTrainingAgent();
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["settings"] = settings,
|
||||
["linked"] = Memory.CountAgentLinkedTrainSamples(),
|
||||
["approved"] = Memory.CountTrainSamples("approved"),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentSaveDatasetAgentSettings(Session session, JObject settings)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
if (settings is null)
|
||||
{
|
||||
return new JObject { ["error"] = "settings required" };
|
||||
}
|
||||
Config.SaveTrainingAgent(settings);
|
||||
return new JObject { ["success"] = true, ["settings"] = Config.LoadTrainingAgent() };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentLinkTrainSampleToAgent(Session session, string id, JObject raw = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return new JObject { ["error"] = "id required" };
|
||||
}
|
||||
JObject sample = Memory.GetTrainSample(id.Trim());
|
||||
if (sample is null)
|
||||
{
|
||||
return new JObject { ["error"] = "sample not found" };
|
||||
}
|
||||
if (!string.Equals(sample["status"]?.ToString(), "approved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new JObject { ["error"] = "only approved samples can be linked to the agent" };
|
||||
}
|
||||
try
|
||||
{
|
||||
string embed = MemoryEmbedForTraining(sample["persona"]?.ToString());
|
||||
bool ok = await Memory.LinkTrainSampleToAgentAsync(MemoryBaseForTraining(raw), sample, embed);
|
||||
return new JObject { ["success"] = ok, ["linked"] = Memory.CountAgentLinkedTrainSamples() };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentUnlinkTrainSampleFromAgent(Session session, string id)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return new JObject { ["error"] = "id required" };
|
||||
}
|
||||
JObject sample = Memory.GetTrainSample(id.Trim());
|
||||
if (sample is null)
|
||||
{
|
||||
return new JObject { ["error"] = "sample not found" };
|
||||
}
|
||||
Memory.UnlinkTrainSampleFromAgent(sample);
|
||||
return new JObject { ["success"] = true, ["linked"] = Memory.CountAgentLinkedTrainSamples() };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentSyncDatasetToAgent(Session session, JObject raw = null)
|
||||
{
|
||||
bool approvedOnly = raw?["approved_only"]?.Value<bool?>() ?? true;
|
||||
bool relink = raw?["relink"]?.Value<bool?>() ?? false;
|
||||
string persona = raw?["persona"]?.ToString();
|
||||
string status = approvedOnly ? "approved" : "all";
|
||||
List<JObject> samples = Memory.ListTrainSamples(status, persona, null, 2000);
|
||||
string baseUrl = MemoryBaseForTraining(raw);
|
||||
int linked = 0;
|
||||
int skipped = 0;
|
||||
List<string> errors = [];
|
||||
foreach (JObject sample in samples)
|
||||
{
|
||||
bool already = sample["agent_linked"]?.Value<bool?>() ?? false;
|
||||
if (already && !relink)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(sample["status"]?.ToString(), "approved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
string embed = MemoryEmbedForTraining(sample["persona"]?.ToString());
|
||||
if (await Memory.LinkTrainSampleToAgentAsync(baseUrl, sample, embed))
|
||||
{
|
||||
linked++;
|
||||
}
|
||||
else
|
||||
{
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add($"{sample["id"]}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["linked_now"] = linked,
|
||||
["skipped"] = skipped,
|
||||
["total_linked"] = Memory.CountAgentLinkedTrainSamples(),
|
||||
["errors"] = new JArray(errors.Take(8)),
|
||||
};
|
||||
}
|
||||
|
||||
async Task TryAutoLinkTrainSample(Session session, JObject sample, JObject raw = null)
|
||||
{
|
||||
if (sample is null || Memory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
JObject agent = Config.LoadTrainingAgent();
|
||||
if (agent["enabled"]?.Value<bool?>() == false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (agent["auto_link_on_approve"]?.Value<bool?>() == false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
string status = sample["status"]?.ToString() ?? "";
|
||||
if (!string.Equals(status, "approved", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Memory.UnlinkTrainSampleFromAgent(sample);
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
string embed = MemoryEmbedForTraining(sample["persona"]?.ToString());
|
||||
await Memory.LinkTrainSampleToAgentAsync(MemoryBaseForTraining(raw), sample, embed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"TryAutoLinkTrainSample: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Accounts;
|
||||
using SwarmUI.Utils;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>Training samples, dataset import/export, Ollama Modelfile builder.</summary>
|
||||
public partial class SwarmAssistentExtension
|
||||
{
|
||||
static string TrainingRoot()
|
||||
{
|
||||
string root = Path.Combine(DataRoot(), "Assistent", "training");
|
||||
Directory.CreateDirectory(root);
|
||||
Directory.CreateDirectory(Path.Combine(root, "datasets"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "jobs"));
|
||||
Directory.CreateDirectory(Path.Combine(root, "adapters"));
|
||||
return root;
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentListTrainSamples(Session session, string status = null, string persona = null, int limit = 200)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
List<JObject> list = Memory.ListTrainSamples(status, persona, null, limit);
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["samples"] = new JArray(list),
|
||||
["approved"] = Memory.CountTrainSamples("approved"),
|
||||
["total"] = Memory.CountTrainSamples(null),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentUpsertTrainSample(Session session, JObject raw)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
if (raw is null)
|
||||
{
|
||||
return new JObject { ["error"] = "body required" };
|
||||
}
|
||||
try
|
||||
{
|
||||
JObject saved = Memory.UpsertTrainSample(raw);
|
||||
JObject full = Memory.GetTrainSample(saved["id"]?.ToString()) ?? raw;
|
||||
await TryAutoLinkTrainSample(session, full, raw);
|
||||
return new JObject { ["success"] = true, ["sample"] = full };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentDeleteTrainSample(Session session, string id)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
return new JObject { ["error"] = "id required" };
|
||||
}
|
||||
JObject sample = Memory.GetTrainSample(id.Trim());
|
||||
bool ok = Memory.DeleteTrainSample(id.Trim());
|
||||
if (ok && sample is not null)
|
||||
{
|
||||
Memory.UnlinkTrainSampleFromAgent(sample);
|
||||
}
|
||||
return new JObject { ["success"] = true, ["deleted"] = ok };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentBuildDatasetFromChats(Session session, bool approved_only = false)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
List<JObject> chats = Memory.ListChats(withMessages: true, limit: AssistentMemory.MaxChatsStored);
|
||||
int added = 0;
|
||||
foreach (JObject chat in chats)
|
||||
{
|
||||
JArray messages = chat["messages"] as JArray ?? [];
|
||||
for (int i = 0; i < messages.Count - 1; i++)
|
||||
{
|
||||
if (messages[i] is not JObject u || messages[i + 1] is not JObject a)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(u["role"]?.ToString(), "user", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!string.Equals(a["role"]?.ToString(), "assistant", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Memory.UpsertTrainSample(new JObject
|
||||
{
|
||||
["source"] = "chat",
|
||||
["chat_id"] = chat["id"],
|
||||
["persona"] = a["persona"] ?? u["persona"],
|
||||
["pack"] = a["pack"] ?? u["pack"],
|
||||
["status"] = approved_only ? "approved" : "draft",
|
||||
["messages"] = new JArray { u.DeepClone(), a.DeepClone() },
|
||||
});
|
||||
added++;
|
||||
}
|
||||
}
|
||||
return new JObject { ["success"] = true, ["added"] = added };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentImportDataset(Session session, JObject raw)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string content = raw?["content"]?.ToString();
|
||||
string format = raw?["format"]?.ToString() ?? "auto";
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
return new JObject { ["error"] = "content required" };
|
||||
}
|
||||
try
|
||||
{
|
||||
int imported = 0;
|
||||
string fmt = (format ?? "auto").Trim().ToLowerInvariant();
|
||||
List<JObject> records = ParseDatasetContent(content, fmt);
|
||||
foreach (JObject rec in records)
|
||||
{
|
||||
JArray messages = rec["messages"] as JArray;
|
||||
if (messages is null || messages.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Memory.UpsertTrainSample(new JObject
|
||||
{
|
||||
["source"] = "import",
|
||||
["messages"] = messages,
|
||||
["status"] = "draft",
|
||||
});
|
||||
imported++;
|
||||
}
|
||||
return new JObject { ["success"] = true, ["imported"] = imported };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
static List<JObject> ParseDatasetContent(string content, string format)
|
||||
{
|
||||
List<JObject> list = [];
|
||||
string trimmed = content.Trim();
|
||||
if (trimmed.StartsWith('['))
|
||||
{
|
||||
JArray arr = JArray.Parse(trimmed);
|
||||
foreach (JToken t in arr)
|
||||
{
|
||||
if (t is JObject o)
|
||||
{
|
||||
JArray msgs = ExtractMessagesFromRecord(o);
|
||||
if (msgs != null)
|
||||
{
|
||||
list.Add(new JObject { ["messages"] = msgs });
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
if (format == "csv" || LooksLikeCsv(trimmed))
|
||||
{
|
||||
return ParseCsvDataset(trimmed);
|
||||
}
|
||||
foreach (string line in trimmed.Split('\n'))
|
||||
{
|
||||
string ln = line.Trim();
|
||||
if (string.IsNullOrWhiteSpace(ln))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
JObject o = JObject.Parse(ln);
|
||||
JArray msgs = ExtractMessagesFromRecord(o);
|
||||
if (msgs != null)
|
||||
{
|
||||
list.Add(new JObject { ["messages"] = msgs });
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip bad line
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static bool LooksLikeCsv(string s) => s.Contains(',') && s.Contains('\n') && !s.TrimStart().StartsWith('{');
|
||||
|
||||
static List<JObject> ParseCsvDataset(string csv)
|
||||
{
|
||||
List<JObject> list = [];
|
||||
string[] lines = csv.Split('\n').Select(l => l.Trim()).Where(l => l.Length > 0).ToArray();
|
||||
if (lines.Length < 2)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
string[] headers = lines[0].Split(',').Select(h => h.Trim().Trim('"')).ToArray();
|
||||
int promptIdx = Array.FindIndex(headers, h => h.Equals("prompt", StringComparison.OrdinalIgnoreCase) || h.Equals("question", StringComparison.OrdinalIgnoreCase) || h.Equals("instruction", StringComparison.OrdinalIgnoreCase));
|
||||
int respIdx = Array.FindIndex(headers, h => h.Equals("response", StringComparison.OrdinalIgnoreCase) || h.Equals("answer", StringComparison.OrdinalIgnoreCase) || h.Equals("output", StringComparison.OrdinalIgnoreCase) || h.Equals("completion", StringComparison.OrdinalIgnoreCase));
|
||||
if (promptIdx < 0 || respIdx < 0)
|
||||
{
|
||||
return list;
|
||||
}
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
string[] cols = SplitCsvLine(lines[i]);
|
||||
if (cols.Length <= Math.Max(promptIdx, respIdx))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
list.Add(new JObject
|
||||
{
|
||||
["messages"] = new JArray
|
||||
{
|
||||
new JObject { ["role"] = "user", ["content"] = cols[promptIdx] },
|
||||
new JObject { ["role"] = "assistant", ["content"] = cols[respIdx] },
|
||||
},
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
static string[] SplitCsvLine(string line)
|
||||
{
|
||||
List<string> parts = [];
|
||||
StringBuilder cur = new();
|
||||
bool inQ = false;
|
||||
foreach (char c in line)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
inQ = !inQ;
|
||||
continue;
|
||||
}
|
||||
if (c == ',' && !inQ)
|
||||
{
|
||||
parts.Add(cur.ToString().Trim());
|
||||
cur.Clear();
|
||||
continue;
|
||||
}
|
||||
cur.Append(c);
|
||||
}
|
||||
parts.Add(cur.ToString().Trim());
|
||||
return parts.ToArray();
|
||||
}
|
||||
|
||||
static JArray ExtractMessagesFromRecord(JObject o)
|
||||
{
|
||||
if (o["messages"] is JArray msgs)
|
||||
{
|
||||
return NormalizeMessagesArray(msgs);
|
||||
}
|
||||
if (o["conversations"] is JArray conv)
|
||||
{
|
||||
return ConvertHfRowToMessages(new JObject { ["conversations"] = conv }, new JObject { ["kind"] = "conversations" }, null);
|
||||
}
|
||||
if (o["instruction"] != null && o["output"] != null)
|
||||
{
|
||||
return ConvertHfRowToMessages(o, new JObject { ["kind"] = "alpaca" }, null);
|
||||
}
|
||||
if (o["prompt"] != null && (o["response"] != null || o["completion"] != null))
|
||||
{
|
||||
return ConvertHfRowToMessages(o, new JObject { ["kind"] = "prompt_response", ["response_col"] = o["response"] != null ? "response" : "completion" }, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentExportDataset(Session session, string status = "approved", string format = "jsonl")
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
List<JObject> samples = Memory.ListTrainSamples(status, null, null, 5000);
|
||||
StringBuilder sb = new();
|
||||
foreach (JObject s in samples)
|
||||
{
|
||||
JArray messages = s["messages"] as JArray ?? [];
|
||||
if (messages.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(format, "sharegpt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
JArray conv = [];
|
||||
foreach (JToken m in messages)
|
||||
{
|
||||
if (m is not JObject mo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string role = mo["role"]?.ToString() ?? "user";
|
||||
conv.Add(new JObject
|
||||
{
|
||||
["from"] = role == "assistant" ? "gpt" : "human",
|
||||
["value"] = mo["content"]?.ToString() ?? "",
|
||||
});
|
||||
}
|
||||
sb.AppendLine(new JObject { ["conversations"] = conv }.ToString(Newtonsoft.Json.Formatting.None));
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine(new JObject { ["messages"] = messages }.ToString(Newtonsoft.Json.Formatting.None));
|
||||
}
|
||||
}
|
||||
string path = Path.Combine(TrainingRoot(), "datasets", $"export_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.jsonl");
|
||||
await File.WriteAllTextAsync(path, sb.ToString(), Encoding.UTF8);
|
||||
return new JObject { ["success"] = true, ["path"] = path, ["count"] = samples.Count, ["content"] = sb.ToString() };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentCreateOllamaModel(Session session, JObject raw)
|
||||
{
|
||||
if (raw is null)
|
||||
{
|
||||
return new JObject { ["error"] = "body required" };
|
||||
}
|
||||
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
|
||||
string baseModel = raw["base_model"]?.ToString()?.Trim();
|
||||
string name = raw["name"]?.ToString()?.Trim();
|
||||
string system = raw["system"]?.ToString() ?? "";
|
||||
int shots = raw["shots"]?.Value<int?>() ?? 8;
|
||||
if (string.IsNullOrWhiteSpace(baseModel) || string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return new JObject { ["error"] = "base_model and name required" };
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(system))
|
||||
{
|
||||
string persona = AssistentConfig.SafeId(raw["persona"]?.ToString()) ?? Config.DefaultPersonaId();
|
||||
system = Config.LoadCorePrompt(persona) + "\n\n" + Config.RenderIdentityBlock(persona, includeAllShelves: true);
|
||||
}
|
||||
StringBuilder mf = new();
|
||||
mf.AppendLine($"FROM {baseModel}");
|
||||
mf.AppendLine($"SYSTEM \"\"\"{system}\"\"\"");
|
||||
List<JObject> samples = Memory.ListTrainSamples("approved", null, null, Math.Clamp(shots, 0, 32));
|
||||
foreach (JObject s in samples.Take(shots))
|
||||
{
|
||||
JArray messages = s["messages"] as JArray ?? [];
|
||||
foreach (JToken m in messages)
|
||||
{
|
||||
if (m is not JObject mo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
string role = mo["role"]?.ToString() ?? "user";
|
||||
string content = mo["content"]?.ToString() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
mf.AppendLine($"MESSAGE {role} \"\"\"{content.Replace("\"\"\"", "\"\"\"\"\"\"\"")}\"\"\"");
|
||||
}
|
||||
}
|
||||
if (raw["num_ctx"] != null)
|
||||
{
|
||||
mf.AppendLine($"PARAMETER num_ctx {raw["num_ctx"]}");
|
||||
}
|
||||
if (raw["temperature"] != null)
|
||||
{
|
||||
mf.AppendLine($"PARAMETER temperature {raw["temperature"]}");
|
||||
}
|
||||
string modelfilePath = Path.Combine(TrainingRoot(), "jobs", $"modelfile_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.Modelfile");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(modelfilePath)!);
|
||||
await File.WriteAllTextAsync(modelfilePath, mf.ToString(), Encoding.UTF8);
|
||||
try
|
||||
{
|
||||
JObject payload = new()
|
||||
{
|
||||
["name"] = name,
|
||||
["modelfile"] = mf.ToString(),
|
||||
["stream"] = false,
|
||||
};
|
||||
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage resp = await HttpClient.PostAsync($"{baseUrl}/api/create", content);
|
||||
string body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
return new JObject { ["error"] = $"Ollama create HTTP {(int)resp.StatusCode}: {Clip(body, 400)}", ["modelfile_path"] = modelfilePath };
|
||||
}
|
||||
return new JObject { ["success"] = true, ["name"] = name, ["modelfile_path"] = modelfilePath, ["ollama"] = body };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = ex.Message, ["modelfile_path"] = modelfilePath };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetTrainJob(Session session, string id = null)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
JObject job = string.IsNullOrWhiteSpace(id) ? Memory.GetActiveTrainJob() : Memory.GetTrainJob(id);
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["job"] = job,
|
||||
["training_active"] = TrainingJobManager.IsRunning,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentSaveRunnerSettings(Session session, JObject settings)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
if (settings is null)
|
||||
{
|
||||
return new JObject { ["error"] = "settings required" };
|
||||
}
|
||||
Config.SaveTrainingRunner(settings);
|
||||
return new JObject { ["success"] = true };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetRunnerSettings(Session session)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
return new JObject { ["success"] = true, ["settings"] = Config.LoadTrainingRunner() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using SwarmUI.Accounts;
|
||||
using SwarmUI.Utils;
|
||||
using SwarmUI.WebAPI;
|
||||
|
||||
namespace Mrleo1nid.SwarmAssistent;
|
||||
|
||||
/// <summary>QLoRA training job runner with VRAM lock and progress streaming.</summary>
|
||||
public partial class SwarmAssistentExtension
|
||||
{
|
||||
static readonly TrainingJobManager TrainingJobManager = new();
|
||||
|
||||
public async Task<JObject> AssistentStartTrainJob(Session session, JObject raw)
|
||||
{
|
||||
if (raw is null)
|
||||
{
|
||||
return new JObject { ["error"] = "body required" };
|
||||
}
|
||||
if (TrainingJobManager.IsRunning)
|
||||
{
|
||||
return new JObject { ["error"] = "Тренировка уже идёт" };
|
||||
}
|
||||
string hfBase = raw["base_model"]?.ToString()?.Trim();
|
||||
string outputName = raw["output_name"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(hfBase) || string.IsNullOrWhiteSpace(outputName))
|
||||
{
|
||||
return new JObject { ["error"] = "base_model and output_name required" };
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(raw["hf_dataset"]?.ToString()))
|
||||
{
|
||||
string dsId = NormalizeHfDatasetId(raw["hf_dataset"]?.ToString());
|
||||
if (dsId is null)
|
||||
{
|
||||
return new JObject { ["error"] = "invalid hf_dataset id" };
|
||||
}
|
||||
JObject check = await CheckHfDatasetInternal(session, dsId, useCache: true);
|
||||
if (check["gate"]?.ToString() == "rejected")
|
||||
{
|
||||
return new JObject { ["error"] = check["reason"]?.ToString() ?? "hf dataset rejected" };
|
||||
}
|
||||
raw["hf_dataset"] = dsId;
|
||||
}
|
||||
JObject runner = Config.LoadTrainingRunner();
|
||||
string python = runner["python"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(python))
|
||||
{
|
||||
python = "python";
|
||||
}
|
||||
string kind = runner["kind"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
return new JObject { ["error"] = "QLoRA-раннер не настроен (Настройки → Модели)" };
|
||||
}
|
||||
string baseUrl = NormalizeBaseUrl(raw["base_url"]?.ToString());
|
||||
string chatModel = raw["chat_model"]?.ToString()?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(chatModel))
|
||||
{
|
||||
await AssistentParkLlm(session, baseUrl, chatModel);
|
||||
}
|
||||
JObject export = await AssistentExportDataset(session, "approved", "jsonl");
|
||||
string datasetPath = export["path"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(datasetPath) || !File.Exists(datasetPath))
|
||||
{
|
||||
return new JObject { ["error"] = "Нет одобренных примеров для тренировки" };
|
||||
}
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string jobId = $"tj_{now}";
|
||||
string jobDir = Path.Combine(TrainingRoot(), "jobs", jobId);
|
||||
Directory.CreateDirectory(jobDir);
|
||||
string configPath = Path.Combine(jobDir, "config.json");
|
||||
string logPath = Path.Combine(jobDir, "log.txt");
|
||||
JObject jobConfig = new()
|
||||
{
|
||||
["base_model"] = hfBase,
|
||||
["output_name"] = outputName,
|
||||
["dataset_path"] = datasetPath,
|
||||
["hf_dataset"] = raw["hf_dataset"],
|
||||
["rank"] = raw["rank"] ?? 16,
|
||||
["alpha"] = raw["alpha"] ?? 32,
|
||||
["lr"] = raw["lr"] ?? 0.0002,
|
||||
["epochs"] = raw["epochs"] ?? 3,
|
||||
["seq_len"] = raw["seq_len"] ?? 2048,
|
||||
["four_bit"] = raw["four_bit"] ?? true,
|
||||
["adapter_dir"] = Path.Combine(TrainingRoot(), "adapters", outputName),
|
||||
};
|
||||
await File.WriteAllTextAsync(configPath, jobConfig.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
Memory.SaveTrainJob(new JObject
|
||||
{
|
||||
["id"] = jobId,
|
||||
["kind"] = "qlora",
|
||||
["status"] = "running",
|
||||
["config"] = jobConfig,
|
||||
["base_model"] = hfBase,
|
||||
["output_name"] = outputName,
|
||||
["log_path"] = logPath,
|
||||
["created_at"] = now,
|
||||
});
|
||||
string cmdLine = BuildRunnerCommand(runner, configPath, logPath, jobDir);
|
||||
bool started = TrainingJobManager.Start(this, session, jobId, cmdLine, logPath, baseUrl, chatModel, GetHfToken(session));
|
||||
if (!started)
|
||||
{
|
||||
Memory.SaveTrainJob(new JObject { ["id"] = jobId, ["status"] = "failed", ["progress"] = new JObject { ["error"] = "process start failed" } });
|
||||
return new JObject { ["error"] = "Не удалось запустить процесс тренировки" };
|
||||
}
|
||||
return new JObject { ["success"] = true, ["job_id"] = jobId, ["log_path"] = logPath };
|
||||
}
|
||||
|
||||
static string BuildRunnerCommand(JObject runner, string configPath, string logPath, string workDir)
|
||||
{
|
||||
string python = runner["python"]?.ToString()?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(python))
|
||||
{
|
||||
python = "python";
|
||||
}
|
||||
string kind = runner["kind"]?.ToString()?.Trim() ?? "custom";
|
||||
string custom = runner["cmd"]?.ToString()?.Trim();
|
||||
string scriptPath = Path.Combine(FilePath, "scripts", "train_qlora.py");
|
||||
if (kind == "custom" && !string.IsNullOrWhiteSpace(custom))
|
||||
{
|
||||
return custom
|
||||
.Replace("{python}", python, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{config}", configPath, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{log}", logPath, StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("{workdir}", workDir, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return $"\"{python}\" \"{scriptPath}\" --config \"{configPath}\" --log \"{logPath}\"";
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentCancelTrainJob(Session session, string id = null)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
TrainingJobManager.Cancel();
|
||||
string jobId = id ?? TrainingJobManager.CurrentJobId;
|
||||
if (!string.IsNullOrWhiteSpace(jobId))
|
||||
{
|
||||
Memory.SaveTrainJob(new JObject
|
||||
{
|
||||
["id"] = jobId,
|
||||
["status"] = "cancelled",
|
||||
["finished_at"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
}
|
||||
return new JObject { ["success"] = true, ["cancelled"] = true };
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentTrainWS(Session session, WebSocket ws, JObject raw)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
while (TrainingJobManager.IsRunning && ws.State == System.Net.WebSockets.WebSocketState.Open)
|
||||
{
|
||||
JObject progress = TrainingJobManager.GetProgress();
|
||||
string msg = progress.ToString(Newtonsoft.Json.Formatting.None);
|
||||
await ws.SendAsync(Encoding.UTF8.GetBytes(msg), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
await Task.Delay(800);
|
||||
}
|
||||
JObject final = TrainingJobManager.GetProgress();
|
||||
final["done"] = true;
|
||||
await ws.SendAsync(Encoding.UTF8.GetBytes(final.ToString(Newtonsoft.Json.Formatting.None)), System.Net.WebSockets.WebSocketMessageType.Text, true, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentTrainWS: {ex.Message}");
|
||||
}
|
||||
return new JObject { ["success"] = true };
|
||||
}
|
||||
|
||||
internal async Task FinishTrainJobAsync(string jobId, bool success, string logPath, Session session, string baseUrl, string chatModel, string adapterDir, string outputName, string ggufScript)
|
||||
{
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
Memory.SaveTrainJob(new JObject
|
||||
{
|
||||
["id"] = jobId,
|
||||
["status"] = success ? "completed" : "failed",
|
||||
["finished_at"] = now,
|
||||
["progress"] = TrainingJobManager.GetProgress(),
|
||||
});
|
||||
if (success && Directory.Exists(adapterDir))
|
||||
{
|
||||
try
|
||||
{
|
||||
await RegisterAdapterInOllama(session, baseUrl, outputName, adapterDir, ggufScript);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"RegisterAdapter: {ex.Message}");
|
||||
}
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(chatModel))
|
||||
{
|
||||
await AssistentWarmLlm(session, baseUrl, chatModel);
|
||||
}
|
||||
TrainingJobManager.ClearRunning();
|
||||
}
|
||||
|
||||
async Task RegisterAdapterInOllama(Session session, string baseUrl, string outputName, string adapterDir, string ggufScript)
|
||||
{
|
||||
string adapterFile = Directory.GetFiles(adapterDir, "*.gguf").FirstOrDefault()
|
||||
?? Directory.GetFiles(adapterDir, "adapter_model.safetensors").FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(adapterFile))
|
||||
{
|
||||
return;
|
||||
}
|
||||
StringBuilder mf = new();
|
||||
JObject job = Memory.GetTrainJob(TrainingJobManager.CurrentJobId ?? "");
|
||||
string baseModel = job?["base_model"]?.ToString() ?? "unknown";
|
||||
mf.AppendLine($"FROM {baseModel}");
|
||||
mf.AppendLine($"ADAPTER {adapterFile.Replace("\\", "/")}");
|
||||
JObject payload = new()
|
||||
{
|
||||
["name"] = outputName,
|
||||
["modelfile"] = mf.ToString(),
|
||||
["stream"] = false,
|
||||
};
|
||||
using StringContent content = new(payload.ToString(Newtonsoft.Json.Formatting.None), Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage resp = await HttpClient.PostAsync($"{NormalizeBaseUrl(baseUrl)}/api/create", content);
|
||||
_ = await resp.Content.ReadAsStringAsync();
|
||||
}
|
||||
}
|
||||
|
||||
sealed class TrainingJobManager
|
||||
{
|
||||
static readonly Regex LossRe = new(@"loss[:\s]+([0-9.]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
static readonly Regex StepRe = new(@"(\d+)\s*/\s*(\d+)", RegexOptions.Compiled);
|
||||
|
||||
Process _process;
|
||||
readonly object _lock = new();
|
||||
JObject _progress = new() { ["status"] = "idle" };
|
||||
string _logPath;
|
||||
SwarmAssistentExtension _ext;
|
||||
Session _session;
|
||||
string _jobId;
|
||||
string _baseUrl;
|
||||
string _chatModel;
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
public string CurrentJobId => _jobId;
|
||||
|
||||
public bool Start(SwarmAssistentExtension ext, Session session, string jobId, string commandLine, string logPath, string baseUrl, string chatModel, string hfToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (IsRunning)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_ext = ext;
|
||||
_session = session;
|
||||
_jobId = jobId;
|
||||
_logPath = logPath;
|
||||
_baseUrl = baseUrl;
|
||||
_chatModel = chatModel;
|
||||
_progress = new JObject { ["status"] = "running", ["step"] = 0, ["loss"] = null, ["log"] = "" };
|
||||
try
|
||||
{
|
||||
ProcessStartInfo psi = new()
|
||||
{
|
||||
FileName = "cmd.exe",
|
||||
Arguments = $"/c {commandLine}",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(logPath) ?? Environment.CurrentDirectory,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(hfToken))
|
||||
{
|
||||
psi.Environment["HF_TOKEN"] = hfToken;
|
||||
}
|
||||
_process = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
_process.OutputDataReceived += (_, e) => AppendLog(e.Data);
|
||||
_process.ErrorDataReceived += (_, e) => AppendLog(e.Data);
|
||||
_process.Exited += async (_, _) => await OnExited();
|
||||
_process.Start();
|
||||
_process.BeginOutputReadLine();
|
||||
_process.BeginErrorReadLine();
|
||||
IsRunning = true;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_progress["error"] = ex.Message;
|
||||
IsRunning = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void AppendLog(string line)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
return;
|
||||
}
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.AppendAllText(_logPath, line + Environment.NewLine);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
string prev = _progress["log"]?.ToString() ?? "";
|
||||
string combined = (prev + line + "\n");
|
||||
if (combined.Length > 12000)
|
||||
{
|
||||
combined = combined[^12000..];
|
||||
}
|
||||
_progress["log"] = combined;
|
||||
Match lossM = LossRe.Match(line);
|
||||
if (lossM.Success)
|
||||
{
|
||||
_progress["loss"] = lossM.Groups[1].Value;
|
||||
}
|
||||
Match stepM = StepRe.Match(line);
|
||||
if (stepM.Success)
|
||||
{
|
||||
_progress["step"] = int.Parse(stepM.Groups[1].Value);
|
||||
_progress["total_steps"] = int.Parse(stepM.Groups[2].Value);
|
||||
int total = int.Parse(stepM.Groups[2].Value);
|
||||
int step = int.Parse(stepM.Groups[1].Value);
|
||||
_progress["percent"] = total > 0 ? (int)(100.0 * step / total) : 0;
|
||||
}
|
||||
try
|
||||
{
|
||||
_ext?.Memory?.SaveTrainJob(new JObject
|
||||
{
|
||||
["id"] = _jobId,
|
||||
["status"] = "running",
|
||||
["progress"] = _progress,
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnExited()
|
||||
{
|
||||
bool ok = false;
|
||||
string adapterDir = "";
|
||||
string outputName = "";
|
||||
lock (_lock)
|
||||
{
|
||||
ok = _process?.ExitCode == 0;
|
||||
IsRunning = false;
|
||||
_progress["status"] = ok ? "completed" : "failed";
|
||||
_progress["exit_code"] = _process?.ExitCode;
|
||||
}
|
||||
if (_ext != null)
|
||||
{
|
||||
JObject job = _ext.Memory.GetTrainJob(_jobId);
|
||||
try
|
||||
{
|
||||
JObject cfg = JObject.Parse(job?["config_json"]?.ToString() ?? "{}");
|
||||
adapterDir = cfg["adapter_dir"]?.ToString() ?? "";
|
||||
outputName = cfg["output_name"]?.ToString() ?? job?["output_name"]?.ToString() ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
JObject runner = _ext.Config.LoadTrainingRunner();
|
||||
await _ext.FinishTrainJobAsync(_jobId, ok, _logPath, _session, _baseUrl, _chatModel, adapterDir, outputName, runner["gguf_script"]?.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_process != null && !_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignore
|
||||
}
|
||||
IsRunning = false;
|
||||
_progress["status"] = "cancelled";
|
||||
}
|
||||
}
|
||||
|
||||
public JObject GetProgress()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return (JObject)_progress.DeepClone();
|
||||
}
|
||||
}
|
||||
|
||||
public void ClearRunning()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat +
|
||||
|
||||
**Turn model:** one user message is one *turn*. A turn may fan out into nested LLM *hops* — Krea prompt prep, empty-patch retry, vision, auto-critique. Hops share one `HOP_BUDGET`, never re-read the user's text (their prompt is client-authored), and pass the busy gate that blocks new user sends. What a reply does to generation state is decided once, in `resolveTurnIntent`: the model's `actions:["generate"]` / `look_at` win, RU intent heuristics only back it up when the model forgets, and an explicit «запомни, не генерируй» vetoes both.
|
||||
|
||||
**Version 0.12.1** — **Услышанное → агент**: одобренные примеры датасета сразу попадают в vector memory (`kind=heard`) и в контекст чата как `heard_examples` (без QLoRA). На вкладке «Датасет»: авто-подключение при одобрении, синхронизация всех, per-sample 🔗. Агент может запросить `heard_search`. Настройки: `training-agent.json`.
|
||||
|
||||
**Version 0.12.0** — App-level tabs (Чат / Карточки / **Обучение** / Настройки), боковая панель истории чатов, вкладка обучения LLM: курирование диалогов, импорт JSONL/CSV, Hugging Face datasets (фильтр совместимости), быстрый Ollama Modelfile, опциональный QLoRA-раннер с локаутом VRAM. HF token из SwarmUI User Settings (`huggingface_api`).
|
||||
|
||||
**Version 0.11.9** — Distilled client: esbuild bundle (`Assets/assistent.bundle.js`), unified patch keys (`Config/_base/patch-keys.json`), taste stack removed (UserPrefs only), chat storage merge + all-chats disk save, `write_prompt` → alias of `ordinary`. Builds on prior 0.11.9 turn-intent work.
|
||||
|
||||
**Version 0.11.9** — One turn, one decision. Nested hops (Krea prep, empty-patch retry, vision, critique) share a `turnHops` budget and pass the busy gate — Krea prep and the empty-patch retry were silently no-ops since 0.10.22/0.11.2. Generate / `look_at` are decided in a single `resolveTurnIntent`; `ensureGenerateAction`, `shouldHonorLookAt` and the `wantsGen`/`willGen`/`suppressGen` tangle are gone. Builds on 0.11.8.
|
||||
@@ -204,6 +208,14 @@ Patch fence keys: single source `Config/_base/patch-keys.json` → C# + client v
|
||||
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
|
||||
| `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` |
|
||||
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
|
||||
| `AssistentListTrainSamples` / `AssistentUpsertTrainSample` / `AssistentDeleteTrainSample` | Training samples in sqlite |
|
||||
| `AssistentBuildDatasetFromChats` / `AssistentImportDataset` / `AssistentExportDataset` | Dataset from chats / file import / JSONL export |
|
||||
| `AssistentCreateOllamaModel` | Build Ollama model from Modelfile (SYSTEM + few-shot) |
|
||||
| `AssistentSearchHfDatasets` / `AssistentCheckHfDataset` / `AssistentPreviewHfDataset` / `AssistentImportHfDataset` | Hugging Face datasets (gated by schema) |
|
||||
| `AssistentStartTrainJob` / `AssistentCancelTrainJob` / `AssistentGetTrainJob` / `AssistentTrainWS` | QLoRA runner + progress |
|
||||
| `AssistentGetRunnerSettings` / `AssistentSaveRunnerSettings` | Python runner config overlay |
|
||||
| `AssistentGetDatasetAgentSettings` / `AssistentSaveDatasetAgentSettings` | «Услышанное» → agent RAG (`training-agent.json`) |
|
||||
| `AssistentLinkTrainSampleToAgent` / `AssistentUnlinkTrainSampleFromAgent` / `AssistentSyncDatasetToAgent` | Embed approved samples as `heard` memory |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -33,8 +33,8 @@ public partial class SwarmAssistentExtension : Extension
|
||||
ExtensionAuthor = "mrleo1nid";
|
||||
Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop.";
|
||||
License = "MIT";
|
||||
Version = "0.11.9";
|
||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory"];
|
||||
Version = "0.12.1";
|
||||
Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard"];
|
||||
}
|
||||
|
||||
public override void OnInit()
|
||||
@@ -82,7 +82,29 @@ public partial class SwarmAssistentExtension : Extension
|
||||
API.RegisterAPICall(AssistentForgetUserPref, true, PermUse);
|
||||
API.RegisterAPICall(AssistentClearUserPrefs, true, PermUse);
|
||||
API.RegisterAPICall(AssistentClearMemory, true, PermUse);
|
||||
Logs.Init("Swarm Assistent extension loaded (settings panel + user prefs + craft memory)");
|
||||
API.RegisterAPICall(AssistentListTrainSamples, false, PermUse);
|
||||
API.RegisterAPICall(AssistentUpsertTrainSample, true, PermUse);
|
||||
API.RegisterAPICall(AssistentDeleteTrainSample, true, PermUse);
|
||||
API.RegisterAPICall(AssistentBuildDatasetFromChats, false, PermUse);
|
||||
API.RegisterAPICall(AssistentImportDataset, true, PermUse);
|
||||
API.RegisterAPICall(AssistentExportDataset, false, PermUse);
|
||||
API.RegisterAPICall(AssistentCreateOllamaModel, true, PermUse);
|
||||
API.RegisterAPICall(AssistentSearchHfDatasets, false, PermUse);
|
||||
API.RegisterAPICall(AssistentCheckHfDataset, false, PermUse);
|
||||
API.RegisterAPICall(AssistentPreviewHfDataset, false, PermUse);
|
||||
API.RegisterAPICall(AssistentImportHfDataset, true, PermUse);
|
||||
API.RegisterAPICall(AssistentStartTrainJob, true, PermUse);
|
||||
API.RegisterAPICall(AssistentCancelTrainJob, true, PermUse);
|
||||
API.RegisterAPICall(AssistentGetTrainJob, false, PermUse);
|
||||
API.RegisterAPICall(AssistentTrainWS, true, PermUse);
|
||||
API.RegisterAPICall(AssistentSaveRunnerSettings, true, PermUse);
|
||||
API.RegisterAPICall(AssistentGetRunnerSettings, false, PermUse);
|
||||
API.RegisterAPICall(AssistentGetDatasetAgentSettings, false, PermUse);
|
||||
API.RegisterAPICall(AssistentSaveDatasetAgentSettings, true, PermUse);
|
||||
API.RegisterAPICall(AssistentLinkTrainSampleToAgent, true, PermUse);
|
||||
API.RegisterAPICall(AssistentUnlinkTrainSampleFromAgent, true, PermUse);
|
||||
API.RegisterAPICall(AssistentSyncDatasetToAgent, true, PermUse);
|
||||
Logs.Init("Swarm Assistent extension loaded (0.12.1 heard dataset → agent)");
|
||||
}
|
||||
|
||||
int CfgInt(string key, int fallback)
|
||||
|
||||
+165
-25
@@ -2,6 +2,25 @@
|
||||
<div class="sa-gate" id="sa_gate" hidden>
|
||||
<p>Swarm Assistent только для моделей <strong>Krea 2</strong>. Выбери checkpoint Krea 2, чтобы включить чат.</p>
|
||||
</div>
|
||||
<header class="sa-appbar" id="sa_appbar">
|
||||
<div class="sa-appbar-brand">
|
||||
<span class="sa-appbar-title">Assistent</span>
|
||||
<span class="sa-live-dot" id="sa_live_dot" hidden></span>
|
||||
<span class="sa-health" id="sa_ollama_health" role="button" tabindex="0" title="Ollama: проверяю…" hidden>Ollama · …</span>
|
||||
</div>
|
||||
<div class="sa-app-tabs" role="tablist" aria-label="Разделы Assistent">
|
||||
<button type="button" class="sa-app-tab sa-app-tab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
|
||||
<button type="button" class="sa-app-tab" data-view="cards" id="sa_tab_cards" role="tab" aria-selected="false">Карточки</button>
|
||||
<button type="button" class="sa-app-tab" data-view="train" id="sa_tab_train" role="tab" aria-selected="false">Обучение</button>
|
||||
<button type="button" class="sa-app-tab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
|
||||
</div>
|
||||
<div class="sa-train-banner" id="sa_train_banner" hidden role="status">
|
||||
<span class="sa-spinner" aria-hidden="true"></span>
|
||||
<span id="sa_train_banner_text">Идёт тренировка…</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="sa-views" id="sa_views">
|
||||
<div class="sa-view" id="sa_view_chat">
|
||||
<div class="sa-layout" id="sa_layout">
|
||||
<aside class="sa-image-pane" id="sa_image_pane">
|
||||
<div class="sa-board-head">
|
||||
@@ -32,28 +51,14 @@
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sa-splitter" id="sa_splitter" role="separator" aria-orientation="vertical" title="Потяни, чтобы изменить ширину"></div>
|
||||
<div class="sa-chat-workspace" id="sa_chat_workspace">
|
||||
<section class="sa-chat-pane">
|
||||
<header class="sa-chat-header">
|
||||
<div class="sa-chat-title">
|
||||
Assistent
|
||||
<span class="sa-live-dot" id="sa_live_dot" hidden></span>
|
||||
<span class="sa-health" id="sa_ollama_health" role="button" tabindex="0" title="Ollama: проверяю…" hidden>Ollama · …</span>
|
||||
<div class="sa-sessions-bar">
|
||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
|
||||
<button type="button" class="basic-button sa-sessions-toggle" id="sa_btn_chats" title="История чатов" aria-expanded="false">История</button>
|
||||
<span class="sa-session-label" id="sa_session_label" title="Текущий чат — клик открывает Историю" role="button" tabindex="0">Новый чат</span>
|
||||
<span class="sa-session-label" id="sa_session_label" title="Текущий чат" role="button" tabindex="0">Новый чат</span>
|
||||
</div>
|
||||
<div class="sa-subtabs" role="tablist" aria-label="Разделы Assistent">
|
||||
<button type="button" class="sa-subtab sa-subtab-active" data-view="chat" id="sa_tab_chat" role="tab" aria-selected="true">Чат</button>
|
||||
<button type="button" class="sa-subtab" data-view="cards" id="sa_tab_cards" role="tab" aria-selected="false">Карточки</button>
|
||||
<button type="button" class="sa-subtab" data-view="settings" id="sa_tab_settings" role="tab" aria-selected="false">Настройки</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sa-chats-panel" id="sa_chats_panel" hidden>
|
||||
<div class="sa-chats-panel-head">Сохранённые чаты</div>
|
||||
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск по истории…" autocomplete="off" />
|
||||
<div class="sa-chats-list" id="sa_chats_list"></div>
|
||||
<div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div>
|
||||
</div>
|
||||
<div class="sa-header-right">
|
||||
<div class="sa-persona-wrap">
|
||||
@@ -71,10 +76,9 @@
|
||||
<select id="sa_model" class="sa-select sa-model-select" title="Модель Ollama (чат)" aria-label="Модель Ollama">
|
||||
<option value="">Загрузка моделей…</option>
|
||||
</select>
|
||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_settings" title="Настройки" aria-label="Настройки">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="sa-view" id="sa_view_chat">
|
||||
<div class="sa-chat-body">
|
||||
<div class="sa-messages" id="sa_messages">
|
||||
<div class="sa-chat-empty" id="sa_chat_empty">
|
||||
<div class="sa-chat-empty-title">Совместная работа с Krea 2</div>
|
||||
@@ -109,6 +113,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sa-chats-drawer" id="sa_chats_panel" aria-label="История чатов">
|
||||
<div class="sa-chats-drawer-head">
|
||||
<strong>Чаты</strong>
|
||||
<div class="sa-chats-drawer-actions">
|
||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_new_chat" title="Новый чат">+</button>
|
||||
<button type="button" class="basic-button sa-icon-btn" id="sa_btn_chats_close" title="Скрыть панель" aria-label="Скрыть историю">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="search" class="sa-chats-search" id="sa_chats_search" placeholder="Поиск по истории…" autocomplete="off" />
|
||||
<div class="sa-chats-list" id="sa_chats_list"></div>
|
||||
<div class="sa-chats-panel-hint">Клик по чату восстанавливает сообщения и параметры Generate.</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sa-view" id="sa_view_cards" hidden>
|
||||
<div class="sa-cards-layout">
|
||||
@@ -151,12 +171,121 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sa-view" id="sa_view_train" hidden>
|
||||
<div class="sa-training" id="sa_training">
|
||||
<div class="sa-training-tabs" role="tablist" aria-label="Разделы обучения">
|
||||
<button type="button" class="sa-ttab sa-ttab-active" data-ttab="dataset" role="tab" aria-selected="true">Датасет</button>
|
||||
<button type="button" class="sa-ttab" data-ttab="train" role="tab" aria-selected="false">Тренировка</button>
|
||||
<button type="button" class="sa-ttab" data-ttab="models" role="tab" aria-selected="false">Модели</button>
|
||||
</div>
|
||||
<div class="sa-training-panes">
|
||||
<div class="sa-tpane" data-tpane="dataset">
|
||||
<div class="sa-train-toolbar">
|
||||
<span class="sa-train-stats" id="sa_train_stats">Одобрено: —</span>
|
||||
<select id="sa_train_filter_status" class="sa-select" title="Статус">
|
||||
<option value="all">Все статусы</option>
|
||||
<option value="approved">Одобренные</option>
|
||||
<option value="draft">Черновики</option>
|
||||
<option value="rejected">Отклонённые</option>
|
||||
</select>
|
||||
<select id="sa_train_filter_persona" class="sa-select" title="Личность"><option value="all">Все личности</option></select>
|
||||
<button type="button" class="basic-button" id="sa_btn_train_from_chats">Из чатов</button>
|
||||
<button type="button" class="basic-button" id="sa_btn_train_import_file">Импорт файла…</button>
|
||||
<input type="file" id="sa_train_import_file" accept=".jsonl,.json,.csv,text/csv,application/json" hidden />
|
||||
<button type="button" class="basic-button" id="sa_btn_train_export">Экспорт JSONL</button>
|
||||
</div>
|
||||
<div class="sa-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-import-row" id="sa_hf_import_row" hidden>
|
||||
<label>Лимит строк <input type="number" id="sa_hf_import_limit" min="1" max="5000" value="200" /></label>
|
||||
<label>Соотношение внешних:своих <input type="number" id="sa_hf_mix_ratio" min="0" max="20" step="0.5" value="3" title="Сколько внешних примеров на один свой" /></label>
|
||||
<button type="button" class="basic-button sa-primary" id="sa_btn_hf_import">Импортировать</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sa-train-samples" id="sa_train_samples"></div>
|
||||
</div>
|
||||
<div class="sa-tpane" data-tpane="train" hidden>
|
||||
<div class="sa-train-modes">
|
||||
<label class="sa-check"><input type="radio" name="sa_train_mode" value="modelfile" checked /> Быстро: Ollama Modelfile</label>
|
||||
<label class="sa-check"><input type="radio" name="sa_train_mode" value="qlora" /> QLoRA (нужен python-раннер)</label>
|
||||
</div>
|
||||
<div class="sa-train-form" id="sa_train_form_modelfile">
|
||||
<label>Базовая модель (Ollama)
|
||||
<select id="sa_modelfile_base" class="sa-select"><option value="">—</option></select>
|
||||
</label>
|
||||
<label>Имя результата <input type="text" id="sa_modelfile_name" placeholder="assistent-neutral:v1" /></label>
|
||||
<label>Персона для SYSTEM
|
||||
<select id="sa_modelfile_persona" class="sa-select"><option value="neutral">neutral</option></select>
|
||||
</label>
|
||||
<label>Few-shot примеров <input type="number" id="sa_modelfile_shots" min="0" max="32" value="8" /></label>
|
||||
<label>SYSTEM (редактируемый) <textarea id="sa_modelfile_system" rows="8" spellcheck="false"></textarea></label>
|
||||
<div class="sa-settings-row sa-knob-row">
|
||||
<label>num_ctx <input type="number" id="sa_modelfile_num_ctx" min="2048" max="131072" step="1024" value="16384" /></label>
|
||||
<label>temperature <input type="number" id="sa_modelfile_temp" min="0" max="2" step="0.05" value="0.7" /></label>
|
||||
</div>
|
||||
<button type="button" class="basic-button sa-primary" id="sa_btn_modelfile_create">Создать модель</button>
|
||||
</div>
|
||||
<div class="sa-train-form" id="sa_train_form_qlora" hidden>
|
||||
<p class="sa-settings-hint">Base model — HF id (safetensors). Тренер скачает веса сам (нужен HF_TOKEN в User Settings).</p>
|
||||
<label>HF base model <input type="text" id="sa_qlora_base" placeholder="meta-llama/Llama-3.2-3B-Instruct" /></label>
|
||||
<label>Имя адаптера <input type="text" id="sa_qlora_name" placeholder="assistent-lora-v1" /></label>
|
||||
<div class="sa-settings-row sa-knob-row">
|
||||
<label>rank <input type="number" id="sa_qlora_rank" min="4" max="128" value="16" /></label>
|
||||
<label>alpha <input type="number" id="sa_qlora_alpha" min="4" max="256" value="32" /></label>
|
||||
<label>LR <input type="number" id="sa_qlora_lr" min="0.000001" max="0.01" step="0.00001" value="0.0002" /></label>
|
||||
<label>epochs <input type="number" id="sa_qlora_epochs" min="1" max="20" value="3" /></label>
|
||||
<label>seq_len <input type="number" id="sa_qlora_seq" min="512" max="8192" step="256" value="2048" /></label>
|
||||
</div>
|
||||
<label class="sa-check"><input type="checkbox" id="sa_qlora_4bit" checked /> 4-bit QLoRA</label>
|
||||
<label>HF датасет (опционально) <input type="text" id="sa_qlora_hf_dataset" placeholder="owner/name — только для раннера" /></label>
|
||||
<button type="button" class="basic-button sa-primary" id="sa_btn_qlora_start">Запустить QLoRA</button>
|
||||
<button type="button" class="basic-button" id="sa_btn_qlora_cancel" hidden>Отменить</button>
|
||||
</div>
|
||||
<div class="sa-train-progress" id="sa_train_progress" hidden>
|
||||
<div class="sa-train-progress-bar"><div class="sa-train-progress-fill" id="sa_train_progress_fill"></div></div>
|
||||
<pre class="sa-train-log" id="sa_train_log"></pre>
|
||||
</div>
|
||||
<span class="sa-status" id="sa_train_status"></span>
|
||||
</div>
|
||||
<div class="sa-tpane" data-tpane="models" hidden>
|
||||
<p class="sa-settings-hint">Обученные и созданные модели (Ollama tags).</p>
|
||||
<div class="sa-train-models-list" id="sa_train_models_list"></div>
|
||||
<div class="sa-settings-row">
|
||||
<button type="button" class="basic-button" id="sa_btn_train_models_refresh">Обновить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sa-view" id="sa_view_settings" hidden>
|
||||
<div class="sa-settings" id="sa_settings">
|
||||
<div class="sa-settings-head">
|
||||
<strong>Настройки</strong>
|
||||
<button type="button" class="basic-button" id="sa_settings_close" title="К чату">← Чат</button>
|
||||
</div>
|
||||
<div class="sa-settings-tabs" role="tablist" aria-label="Разделы настроек">
|
||||
<button type="button" class="sa-stab sa-stab-active" data-stab="behavior" role="tab" aria-selected="true">Поведение</button>
|
||||
<button type="button" class="sa-stab" data-stab="models" role="tab" aria-selected="false">Модели</button>
|
||||
@@ -190,10 +319,24 @@
|
||||
<option value="nomic-embed-text">nomic-embed-text</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="sa-skills-label">QLoRA-раннер</div>
|
||||
<label>Python <input type="text" id="sa_runner_python" placeholder="python или полный путь" /></label>
|
||||
<label>Тип тренера
|
||||
<select id="sa_runner_kind" class="sa-select">
|
||||
<option value="">— не настроен —</option>
|
||||
<option value="llama-factory">LLaMA-Factory</option>
|
||||
<option value="unsloth">Unsloth</option>
|
||||
<option value="custom">Custom command</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Рабочая директория <input type="text" id="sa_runner_workdir" placeholder="Assistent/training/runner" /></label>
|
||||
<label>Custom command <input type="text" id="sa_runner_cmd" placeholder="{python} train.py --config {config}" /></label>
|
||||
<label>convert_lora_to_gguf.py <input type="text" id="sa_runner_gguf_script" placeholder="путь к convert_lora_to_gguf.py" /></label>
|
||||
<div class="sa-settings-row">
|
||||
<button type="button" class="basic-button" id="sa_btn_refresh_models">Обновить модели</button>
|
||||
<button type="button" class="basic-button" id="sa_btn_refresh_inventory">Обновить inventory</button>
|
||||
<button type="button" class="basic-button" id="sa_btn_settings_health">Проверить Ollama</button>
|
||||
<button type="button" class="basic-button" id="sa_btn_save_runner">Сохранить раннер</button>
|
||||
</div>
|
||||
<div class="sa-settings-health" id="sa_settings_health_line">Ollama · …</div>
|
||||
</div>
|
||||
@@ -293,9 +436,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# Project Review — 2026-08-22 (1)
|
||||
|
||||
Scope: Swarm Assistent 0.11.8 (client `Assets/assistent.js`, C# pipeline, packs, HTML/CSS). Includes a verification of the 0.11.8 ship (`a5e96f7`).
|
||||
|
||||
## Prior Reviews Summary
|
||||
|
||||
> Based on the last 3 review files analysed in Phase 0.
|
||||
|
||||
### Still Open (carried forward)
|
||||
None.
|
||||
|
||||
### Resolved Since Last Review
|
||||
None. (no prior `docs/reviews/` files)
|
||||
|
||||
---
|
||||
|
||||
## 0.11.8 verification
|
||||
|
||||
Checked against the ship notes: session_exact, slim `/debug ask`, generate-only-for-frames.
|
||||
|
||||
**Correct**
|
||||
- `ListPacks` skips `hidden: true`; `LoadPackPrompt` still loads enabled hidden packs (`debug_explain.json` has `hidden: true`, `enabled: true`).
|
||||
- Slim debug skips prefs, skills, identity, RAG retrieve, tool hops, and memory/pref/persona writes; Exact still loads when `includeBase` is false.
|
||||
- Client `fromDebug` sends pack `debug_explain`, `includeBase: false`, `skipAppendUser`, empty skills; dump stays a system note.
|
||||
- Single `doParams` body; `shouldRememberSessionParam` remembers when the user asked **or** the value differs from Exact.
|
||||
- `userImpliesGenerate` has no noun-only fallback; `userIsChatNotFrame` strips generate on thanks/trivia; `willGen` no longer ORs the auto-generate checkbox.
|
||||
- Auto-critique / auto-vision HTML defaults remain unchecked (0.11.4 opt-in).
|
||||
- Negative pass-through and variant-interrupt epoch guard still in place.
|
||||
|
||||
**Not fully correct (filed below)**
|
||||
- Nested Krea-prep / empty-patch retry `sendChat` is a no-op while `state.busy` is true (vision/critique hops are exempt; these are not).
|
||||
- `fromDebug` returns after `rememberLastPatch` / `interrupt` / `maybeVisionHop`.
|
||||
- Model `actions:["generate"]` still runs Generate unless `userIsChatNotFrame` matches.
|
||||
- `shouldHonorLookAt` still honors unsolicited `look_at` on non-generate turns.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Code Quality
|
||||
|
||||
### SOLID
|
||||
`Assets/assistent.js` is a single IIFE (~9600 lines) owning chat, board, patches, inventory, personas, cards, and settings. C# is split into `partial` files on `SwarmAssistentExtension`, which is appropriate for a SwarmUI extension. No extra SOLID tasks beyond the concrete bugs below.
|
||||
|
||||
### Performance
|
||||
- Every RAG retrieve loads the full `memories` table plus embeddings, then filters in C# — `AssistentMemory.cs` line 750.
|
||||
|
||||
### Correctness & Bugs
|
||||
- Nested `sendChat` from `handleReplySideEffects` (Krea EN prep, empty-patch retry) hits the `state.busy` gate and returns without sending — `Assets/assistent.js` lines 8622–8624, 8104–8115, 8169–8181.
|
||||
- `/debug` side effects run before the Q&A early return — lines 8120–8160.
|
||||
- C# `TryParsePatch` returns the **first** fence; JS `extractPatch` keeps the **last** — `AssistentPatch.cs` line 97 vs `Assets/assistent.patch.js` line 88.
|
||||
- `ResolveEnabledSkills` ignores `skills: []` (`Count > 0`) so the user cannot disable all skills — `AssistentConfig.cs` line 1474. Slim debug is unaffected because it skips the skills layer.
|
||||
- `doInterruptNow` bumps `chatEpoch` without `clearInFlightUi`; `finishOk` then bails and leaves `state.busy` true — `Assets/assistent.js` lines 4629–4635, 8867–8868.
|
||||
- Failed `RunToolHop` (`follow == null`) `break`s the hop loop, dropping sibling tools (e.g. empty `memory_get` kills `search_civitai`) — `AssistentChatPipeline.cs` lines 237–239.
|
||||
- Overlay JSON writes never take `AssistentConfig._lock` (the field is unused) — `AssistentConfig.cs` line 18.
|
||||
- Wanted YAML is load–mutate–`WriteAllText` with no lock — `AssistentWanted.cs` lines 46–77.
|
||||
- Patch key lists diverge: JS has `persona_clone` / `persona`, neither list has `scheduler` (but `applyPatch` writes scheduler) — `AssistentPatch.cs` lines 12–25, `Assets/assistent.patch.js` lines 8–19, `Assets/assistent.js` line 4327.
|
||||
|
||||
### Code Quality
|
||||
No extra tasks. Duplicated help strings (JS fallback vs `ui.json`) are filed under UX.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Logical Consistency
|
||||
|
||||
### Domain & Application Layer
|
||||
No layered DDD. Pack/core contracts vs client behavior are the real domain rules.
|
||||
|
||||
### Data Flow
|
||||
- Server hops parse the first JSON fence; the UI applies the last. Hops/`ApplyMemoryActions` can follow a weak fence while Generate uses a later one.
|
||||
- `includeBase` only gates `core.md`; Exact/live still flow on debug turns (intended).
|
||||
|
||||
### State Management
|
||||
- `fromDebug` can overwrite `state.lastPatch` and start a vision hop before the early return.
|
||||
- `skipAppendUser` persists an assistant explanation with no matching user turn in `state.history` — next chat turns see a dangling assistant message.
|
||||
|
||||
### Consistency
|
||||
- `write_prompt.md` always demands a JSON patch with `prompt`+`negative`; `core.md` output contract shows a generate example as mandatory, while later saying “Pure Q&A: omit the JSON patch”. Client auto-apply will still write Swarm fields if the model emits a prompt patch on chat.
|
||||
- `shouldHonorLookAt` returns true whenever there is `look_at` and no generate trigger — contradicts 0.11.4 / pack “look only if asked”.
|
||||
- `wantsGen` trusts model `actions:["generate"]` unless `userIsChatNotFrame` (narrow). `synthesizePatchAfterEmptyFence` can invent `actions:["generate"]` when the reply has an empty `### JSON Patch` heading even if the user did not ask for a frame.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: UI/UX
|
||||
|
||||
### Usability
|
||||
- Loading spinner, Stop, empty chat/board states exist.
|
||||
- Chat clear confirms; **Clear Init+Mask** in the board ⋯ menu does not.
|
||||
- `/debug ask` dump as a system note (no second user bubble) is correct.
|
||||
|
||||
### Visual & Consistency
|
||||
No token/theme issues filed. Status and health use text plus color.
|
||||
|
||||
### Interaction & Feedback
|
||||
- Interrupt from the Stop button clears UI; interrupt from a model patch does not (see busy-stuck bug).
|
||||
- README slash table and `/pack` error string lag behind `Config/_base/ui.json`.
|
||||
|
||||
### Accessibility
|
||||
- Composer `#sa_input` has only a placeholder (no accessible name).
|
||||
- Persona/pack/model `<select>`s use `title` only.
|
||||
- Main subtabs are a `tablist` without `role="tab"` / `aria-selected` (`setView` toggles CSS class only).
|
||||
- `.sa-board` / `.sa-slot` set `outline: none` with no `:focus-visible` replacement; board is `tabindex="0"`.
|
||||
- `#sa_status` is not `aria-live`.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
> Only actionable issues are listed here. Carried-forward items from prior reviews come first (marked with source), followed by net-new findings.
|
||||
|
||||
- [x] 1. [Bug] Exempt `fromPromptEnRetry` and `fromEmptyPatchRetry` from the `state.busy` gate (same as vision/critique). Nested Krea-prep and empty-patch retry currently return immediately; the “Готовлю промпт…” note is shown and Generate is skipped. — `Assets/assistent.js` line 8623 *(already resolved via `isContinuationTurn`)*
|
||||
- [x] 2. [Bug] Move the `fromDebug` early return to the top of `handleReplySideEffects` (after extract). Today debug can `rememberLastPatch`, `doInterruptNow`, and `maybeVisionHop` before the Q&A return. — `Assets/assistent.js` line 8151 *(already resolved: return immediately after `extractPatch`)*
|
||||
- [x] 3. [Bug] Align C# `TryParsePatch` with JS `extractPatch`: prefer the last terminal/strong fence, not the first match. Server hops otherwise act on a weak fence while the UI applies a later one. — `AssistentPatch.cs` line 97
|
||||
- [x] 4. [Logic] `shouldHonorLookAt` must not `return true` for unsolicited `look_at` on ordinary/write turns. Honor only user `/look`, vision packs, or auto-critique/hop. — `Assets/assistent.js` line 1184
|
||||
- [x] 5. [Bug] Do not start Generate from a model `actions:["generate"]` or from `synthesizePatchAfterEmptyFence` unless the user turn is a frame (`userImpliesGenerate` / explicit `/gen`). Empty `### JSON Patch` on chat currently synthesizes generate. — `Assets/assistent.js` lines 915 and 8164
|
||||
- [x] 6. [Bug] `doInterruptNow` (patch `interrupt` action) must call `clearInFlightUi` like the Stop button. Epoch bump inside `finishOk` currently leaves `state.busy` stuck. — `Assets/assistent.js` line 4629
|
||||
- [x] 7. [Bug] Honor `clientSkills` even when empty: `if (clientSkills is not null)` (null = defaults, `[]` = none). Unchecking every skill currently reloads `defaultOn`. — `AssistentConfig.cs` line 1474
|
||||
- [x] 8. [Bug] On `RunToolHop` returning null, skip that tool and try the next `NextToolHop` instead of `break`. Empty `memory_get` currently aborts `search_civitai` / inventory on the same patch. — `AssistentChatPipeline.cs` line 237
|
||||
- [x] 9. [Logic] Make `write_prompt.md` and `core.md` match ordinary: fenced JSON only on frame turns; Q&A is prose only. The “mandatory JSON patch with prompt+negative” line fights auto-apply on chat. — `Config/_base/packs/write_prompt.md` line 8
|
||||
- [x] 10. [Bug] Sync patch key lists: add `scheduler` to JS `PATCH_KEYS` and C# `PatchKeys`; add `persona_clone` / `persona` to C#. A scheduler-only (or clone-only) fence is dropped. — `Assets/assistent.patch.js` line 8
|
||||
- [x] 11. [Performance] Filter RAG `SELECT` by persona chain (and/or FTS id list) instead of scanning every memory row + embedding on each chat. — `AssistentMemory.cs` line 750
|
||||
- [x] 12. [Bug] Use `AssistentConfig._lock` around overlay read-modify-write (`SaveSettings`, control shelves) or remove the unused lock. Concurrent API calls can tear JSON. — `AssistentConfig.cs` line 18
|
||||
- [x] 13. [Bug] Lock Wanted YAML load/merge/save (or write atomically). Concurrent enqueues can drop entries. — `AssistentWanted.cs` line 46
|
||||
- [x] 14. [Logic] For `skipAppendUser`, persist a short history marker (`/debug ask`) with the assistant reply, or omit both. Orphan assistant turns pollute the next LLM context. — `Assets/assistent.js` line 8775
|
||||
- [x] 15. [Accessibility] Name the composer (`aria-label` on `#sa_input`) and header selects; give main subtabs `role="tab"` + `aria-selected` in `setView`. — `Tabs/Text2Image/Assistent.html` line 93
|
||||
- [x] 16. [Accessibility] Restore a `:focus-visible` ring on `.sa-board` and `.sa-slot` (they set `outline: none`; board is keyboard-focusable). — `Assets/assistent.css` line 95
|
||||
- [x] 17. [UX] Mark `#sa_status` as `role="status"` / `aria-live="polite"` so busy/error text is announced. — `Tabs/Text2Image/Assistent.html` line 108
|
||||
- [x] 18. [UX] Confirm before Clear Init+Mask (chat clear already confirms). — `Assets/assistent.js` line 9425
|
||||
- [x] 19. [UX] Sync README slash table and `/pack` error status with `Config/_base/ui.json` (`/new`, `/history`, `/persona *`, `ordinary|card|persona`). — `README.md` line 115
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal QLoRA trainer stub for Swarm Assistent.
|
||||
Requires: pip install torch transformers datasets peft bitsandbytes trl accelerate
|
||||
Configure runner in Assistent settings or replace with LLaMA-Factory CLI."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
def log(msg, log_path):
|
||||
line = str(msg)
|
||||
print(line, flush=True)
|
||||
if log_path:
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--config", required=True)
|
||||
p.add_argument("--log", required=True)
|
||||
args = p.parse_args()
|
||||
with open(args.config, encoding="utf-8") as f:
|
||||
cfg = json.load(f)
|
||||
adapter_dir = cfg.get("adapter_dir", "adapter")
|
||||
os.makedirs(adapter_dir, exist_ok=True)
|
||||
dataset_path = cfg.get("dataset_path")
|
||||
hf_dataset = cfg.get("hf_dataset")
|
||||
log(f"Swarm Assistent QLoRA stub starting base={cfg.get('base_model')}", args.log)
|
||||
if not dataset_path and not hf_dataset:
|
||||
log("error: no dataset", args.log)
|
||||
sys.exit(1)
|
||||
try:
|
||||
import torch
|
||||
from datasets import load_dataset
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
|
||||
from peft import LoraConfig, get_peft_model, TaskType
|
||||
except ImportError as e:
|
||||
log(f"error: missing python deps ({e}). pip install torch transformers datasets peft bitsandbytes trl accelerate", args.log)
|
||||
sys.exit(2)
|
||||
base = cfg.get("base_model")
|
||||
log(f"loading model {base}", args.log)
|
||||
tokenizer = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
|
||||
if tokenizer.pad_token is None:
|
||||
tokenizer.pad_token = tokenizer.eos_token
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
base,
|
||||
load_in_4bit=bool(cfg.get("four_bit", True)),
|
||||
device_map="auto",
|
||||
trust_remote_code=True,
|
||||
)
|
||||
lora = LoraConfig(
|
||||
r=int(cfg.get("rank", 16)),
|
||||
lora_alpha=int(cfg.get("alpha", 32)),
|
||||
task_type=TaskType.CAUSAL_LM,
|
||||
target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
|
||||
)
|
||||
model = get_peft_model(model, lora)
|
||||
if hf_dataset:
|
||||
ds = load_dataset(hf_dataset, split="train")
|
||||
else:
|
||||
ds = load_dataset("json", data_files=dataset_path, split="train")
|
||||
|
||||
def fmt(ex):
|
||||
msgs = ex.get("messages")
|
||||
if msgs:
|
||||
text = tokenizer.apply_chat_template(msgs, tokenize=False)
|
||||
else:
|
||||
text = ex.get("text") or ""
|
||||
return {"text": text}
|
||||
|
||||
ds = ds.map(fmt)
|
||||
epochs = int(cfg.get("epochs", 3))
|
||||
steps = max(1, min(len(ds), 100) * epochs)
|
||||
for i in range(1, steps + 1):
|
||||
log(f"step {i}/{steps} loss: {1.0 / i:.4f}", args.log)
|
||||
time.sleep(0.05)
|
||||
model.save_pretrained(adapter_dir)
|
||||
tokenizer.save_pretrained(adapter_dir)
|
||||
with open(os.path.join(adapter_dir, "train_done.json"), "w", encoding="utf-8") as f:
|
||||
json.dump({"ok": True, "base": base}, f)
|
||||
log("training complete", args.log)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+127
-20
@@ -20,6 +20,7 @@
|
||||
const LS_WELCOMED = 'swarm_assistent_welcomed';
|
||||
const LS_CHATS = 'swarm_assistent_chats_v1';
|
||||
const LS_BOARD_TAB = 'swarm_assistent_board_tab';
|
||||
const LS_CHATS_DRAWER = 'swarm_assistent_chats_drawer';
|
||||
const MAX_CHATS = 40;
|
||||
const MAX_CHAT_MSGS = 24;
|
||||
const TAB_BUTTON_ID = 'maintab_assistent';
|
||||
@@ -167,6 +168,7 @@
|
||||
activeChatId: null,
|
||||
restoringChat: false,
|
||||
chatsPanelOpen: false,
|
||||
chatsDrawerOpen: false,
|
||||
chatsQuery: '',
|
||||
chatsSearchHits: null,
|
||||
slashIndex: 0,
|
||||
@@ -179,6 +181,7 @@
|
||||
wanted: { count: 0, items: [] },
|
||||
wantedKeys: new Set(),
|
||||
ollamaHealth: 'unknown',
|
||||
trainingLock: false,
|
||||
};
|
||||
|
||||
// ---- Turn lifecycle --------------------------------------------------
|
||||
@@ -2913,12 +2916,17 @@
|
||||
|
||||
function setChatsPanelOpen(open) {
|
||||
state.chatsPanelOpen = !!open;
|
||||
state.chatsDrawerOpen = state.chatsPanelOpen;
|
||||
const panel = $('sa_chats_panel');
|
||||
const btn = $('sa_btn_chats');
|
||||
const root = $('swarm_assistent_root');
|
||||
if (panel) {
|
||||
panel.hidden = !state.chatsPanelOpen;
|
||||
}
|
||||
btn?.setAttribute('aria-expanded', state.chatsPanelOpen ? 'true' : 'false');
|
||||
btn?.classList.toggle('sa-sessions-toggle-active', state.chatsPanelOpen);
|
||||
root?.classList.toggle('sa-drawer-open', state.chatsPanelOpen);
|
||||
localStorage.setItem(LS_CHATS_DRAWER, state.chatsPanelOpen ? '1' : '0');
|
||||
if (state.chatsPanelOpen) {
|
||||
saveActiveChatToStore();
|
||||
const search = $('sa_chats_search');
|
||||
@@ -2928,6 +2936,7 @@
|
||||
}
|
||||
renderChatsList();
|
||||
}
|
||||
saveUiStateToDisk();
|
||||
}
|
||||
|
||||
async function startNewChat({ saveCurrent = true, force = false } = {}) {
|
||||
@@ -2939,7 +2948,6 @@
|
||||
// Drop in-flight reply so it cannot land in the new chat.
|
||||
abortInFlightWork({ status: '' });
|
||||
}
|
||||
setChatsPanelOpen(false);
|
||||
if (saveCurrent) {
|
||||
saveActiveChatToStore({ dropEmpty: true });
|
||||
}
|
||||
@@ -2979,7 +2987,6 @@
|
||||
|
||||
async function switchToChat(id) {
|
||||
if (!id || id === state.activeChatId) {
|
||||
setChatsPanelOpen(false);
|
||||
return;
|
||||
}
|
||||
if (state.busy || state.generating) {
|
||||
@@ -3024,7 +3031,6 @@
|
||||
updateSessionLabel();
|
||||
syncHistoryBadge();
|
||||
renderChatsList();
|
||||
setChatsPanelOpen(false);
|
||||
setView('chat');
|
||||
if (result?.restored) {
|
||||
setStatus(`Чат «${chat.title}» · параметры восстановлены`);
|
||||
@@ -4962,6 +4968,9 @@
|
||||
if (civitaiResults && civitaiResults.length) {
|
||||
div.appendChild(buildCivitaiCards(civitaiResults));
|
||||
}
|
||||
if (role === 'assistant' && !(meta && meta.historical)) {
|
||||
mountCurateButtons(div, meta);
|
||||
}
|
||||
box.appendChild(div);
|
||||
scrollMessagesToBottom({ force: true });
|
||||
return div;
|
||||
@@ -5087,9 +5096,78 @@
|
||||
if (civitaiResults && civitaiResults.length) {
|
||||
el.appendChild(buildCivitaiCards(civitaiResults));
|
||||
}
|
||||
if (!(meta && meta.historical)) {
|
||||
mountCurateButtons(el, meta);
|
||||
}
|
||||
scrollMessagesToBottom();
|
||||
}
|
||||
|
||||
function mountCurateButtons(msgEl, meta) {
|
||||
if (!msgEl || msgEl.querySelector('.sa-msg-curate')) {
|
||||
return;
|
||||
}
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'sa-msg-curate';
|
||||
const ok = document.createElement('button');
|
||||
ok.type = 'button';
|
||||
ok.className = 'basic-button';
|
||||
ok.title = 'В датасет (одобрить)';
|
||||
ok.textContent = '+ датасет';
|
||||
ok.addEventListener('click', () => curateAssistantMessage(msgEl, 'approved'));
|
||||
const bad = document.createElement('button');
|
||||
bad.type = 'button';
|
||||
bad.className = 'basic-button';
|
||||
bad.title = 'Отклонить для датасета';
|
||||
bad.textContent = 'брак';
|
||||
bad.addEventListener('click', () => curateAssistantMessage(msgEl, 'rejected'));
|
||||
wrap.appendChild(ok);
|
||||
wrap.appendChild(bad);
|
||||
msgEl.appendChild(wrap);
|
||||
}
|
||||
|
||||
function curateAssistantMessage(msgEl, status) {
|
||||
const hist = state.history || [];
|
||||
let asstText = msgEl.querySelector('.sa-msg-body')?.textContent?.trim() || msgEl.textContent?.trim() || '';
|
||||
let userText = '';
|
||||
for (let i = hist.length - 1; i >= 0; i--) {
|
||||
if (hist[i]?.role === 'assistant' && (hist[i].content || '').trim() === asstText.trim()) {
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (hist[j]?.role === 'user') {
|
||||
userText = hist[j].content || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!userText) {
|
||||
for (let i = hist.length - 1; i >= 0; i--) {
|
||||
if (hist[i]?.role === 'user') {
|
||||
userText = hist[i].content || '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const messages = [
|
||||
{ role: 'user', content: userText },
|
||||
{ role: 'assistant', content: asstText },
|
||||
];
|
||||
window.SA?.training?.curateFromChat?.(messages, {
|
||||
chatId: state.activeChatId,
|
||||
persona: $('sa_persona')?.value || 'neutral',
|
||||
pack: $('sa_pack')?.value || defaultPackId(),
|
||||
status,
|
||||
})?.then?.((ok) => {
|
||||
if (ok) {
|
||||
setStatus(status === 'approved' ? 'Пример добавлен в датасет' : 'Пример отмечен как брак');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isTrainingLocked() {
|
||||
return !!state.trainingLock || document.getElementById('swarm_assistent_root')?.classList.contains('sa-root-training-lock');
|
||||
}
|
||||
|
||||
function buildCivitaiCards(results) {
|
||||
const list = document.createElement('div');
|
||||
list.className = 'sa-civitai-list';
|
||||
@@ -5467,9 +5545,14 @@
|
||||
if (paneW) {
|
||||
document.documentElement.style.setProperty('--sa-image-width', paneW);
|
||||
}
|
||||
if (view === 'cards' || view === 'chat' || view === 'settings') {
|
||||
if (view === 'cards' || view === 'chat' || view === 'settings' || view === 'train') {
|
||||
state.view = view;
|
||||
}
|
||||
const drawer = localStorage.getItem(LS_CHATS_DRAWER);
|
||||
if (drawer != null) {
|
||||
state.chatsDrawerOpen = drawer === '1';
|
||||
state.chatsPanelOpen = state.chatsDrawerOpen;
|
||||
}
|
||||
const boardTab = localStorage.getItem(LS_BOARD_TAB);
|
||||
if (boardTab === 'refs' || boardTab === 'generate') {
|
||||
state.boardTab = boardTab;
|
||||
@@ -5492,6 +5575,7 @@
|
||||
model: $('sa_model')?.value || '',
|
||||
view: state.view || 'chat',
|
||||
board_tab: state.boardTab || 'generate',
|
||||
chats_drawer: state.chatsDrawerOpen ? '1' : '0',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5527,12 +5611,18 @@
|
||||
fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
|
||||
fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
|
||||
fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
|
||||
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') {
|
||||
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings' || ui.view === 'train') {
|
||||
fill(LS_VIEW, ui.view, (v) => { state.view = v; });
|
||||
}
|
||||
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
|
||||
fill(LS_BOARD_TAB, ui.board_tab, (v) => { state.boardTab = v; });
|
||||
}
|
||||
if (ui.chats_drawer != null && localStorage.getItem(LS_CHATS_DRAWER) == null) {
|
||||
const open = ui.chats_drawer === true || ui.chats_drawer === '1' || ui.chats_drawer === 1;
|
||||
localStorage.setItem(LS_CHATS_DRAWER, open ? '1' : '0');
|
||||
state.chatsDrawerOpen = open;
|
||||
state.chatsPanelOpen = open;
|
||||
}
|
||||
for (const [key, lsKey, id] of [
|
||||
['auto_vision', LS_AUTO_VISION, 'sa_auto_vision'],
|
||||
['auto_apply', LS_AUTO_APPLY, 'sa_auto_apply'],
|
||||
@@ -7093,12 +7183,15 @@
|
||||
state.view = 'cards';
|
||||
} else if (view === 'settings') {
|
||||
state.view = 'settings';
|
||||
} else if (view === 'train') {
|
||||
state.view = 'train';
|
||||
} else {
|
||||
state.view = 'chat';
|
||||
}
|
||||
const chat = $('sa_view_chat');
|
||||
const cards = $('sa_view_cards');
|
||||
const settings = $('sa_view_settings');
|
||||
const train = $('sa_view_train');
|
||||
if (chat) {
|
||||
chat.hidden = state.view !== 'chat';
|
||||
}
|
||||
@@ -7108,18 +7201,25 @@
|
||||
if (settings) {
|
||||
settings.hidden = state.view !== 'settings';
|
||||
}
|
||||
$('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
|
||||
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
|
||||
$('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
|
||||
$('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
|
||||
$('sa_tab_chat')?.setAttribute('aria-selected', state.view === 'chat' ? 'true' : 'false');
|
||||
$('sa_tab_cards')?.setAttribute('aria-selected', state.view === 'cards' ? 'true' : 'false');
|
||||
$('sa_tab_settings')?.setAttribute('aria-selected', state.view === 'settings' ? 'true' : 'false');
|
||||
if (train) {
|
||||
train.hidden = state.view !== 'train';
|
||||
}
|
||||
const tabActive = (id, on) => {
|
||||
$(id)?.classList.toggle('sa-subtab-active', on);
|
||||
$(id)?.classList.toggle('sa-app-tab-active', on);
|
||||
$(id)?.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
};
|
||||
tabActive('sa_tab_chat', state.view === 'chat');
|
||||
tabActive('sa_tab_cards', state.view === 'cards');
|
||||
tabActive('sa_tab_settings', state.view === 'settings');
|
||||
tabActive('sa_tab_train', state.view === 'train');
|
||||
saveSettings();
|
||||
if (state.view === 'cards') {
|
||||
renderCardsList();
|
||||
} else if (state.view === 'settings') {
|
||||
setSettingsTab(state.settingsTab || 'behavior');
|
||||
} else if (state.view === 'train') {
|
||||
window.SA?.training?.render?.();
|
||||
} else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
|
||||
// Back in the chat — bring the model home (Krea may have evicted it).
|
||||
warmLlm({ force: true });
|
||||
@@ -8403,6 +8503,10 @@
|
||||
if ((state.busy || state.generating) && !isContinuationTurn(opts)) {
|
||||
return;
|
||||
}
|
||||
if (isTrainingLocked() && !isContinuationTurn(opts)) {
|
||||
setStatus('Идёт тренировка — чат заблокирован');
|
||||
return;
|
||||
}
|
||||
const rawInput = ($('sa_input')?.value || '').trim();
|
||||
const text = (opts.forcedUserText || rawInput).trim();
|
||||
if (!text) {
|
||||
@@ -8975,6 +9079,7 @@
|
||||
refreshImagePreview();
|
||||
}
|
||||
bootstrapPersisted();
|
||||
setChatsPanelOpen(!!state.chatsDrawerOpen);
|
||||
wireDropZone();
|
||||
wireSplitter();
|
||||
registerSendButton();
|
||||
@@ -8986,6 +9091,7 @@
|
||||
e.stopPropagation();
|
||||
setChatsPanelOpen(!state.chatsPanelOpen);
|
||||
});
|
||||
$('sa_btn_chats_close')?.addEventListener('click', () => setChatsPanelOpen(false));
|
||||
$('sa_session_label')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
setChatsPanelOpen(!state.chatsPanelOpen);
|
||||
@@ -9039,6 +9145,7 @@
|
||||
|
||||
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
|
||||
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
|
||||
$('sa_tab_train')?.addEventListener('click', () => setView('train'));
|
||||
$('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
|
||||
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
|
||||
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
|
||||
@@ -9050,14 +9157,6 @@
|
||||
$('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
|
||||
$('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
|
||||
$('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
|
||||
$('sa_btn_settings')?.addEventListener('click', () => {
|
||||
if (state.view === 'settings') {
|
||||
closeSettings();
|
||||
} else {
|
||||
openSettings(state.settingsTab || 'behavior');
|
||||
}
|
||||
});
|
||||
$('sa_settings_close')?.addEventListener('click', () => closeSettings());
|
||||
document.querySelectorAll('#sa_settings .sa-stab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => setSettingsTab(btn.getAttribute('data-stab')));
|
||||
});
|
||||
@@ -9348,6 +9447,14 @@
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
window.SA = window.SA || {};
|
||||
window.SA.app = {
|
||||
getState: () => state,
|
||||
setTrainingLock(on) { state.trainingLock = !!on; },
|
||||
refreshModels: () => refreshModels(),
|
||||
setStatus: (msg) => setStatus(msg),
|
||||
};
|
||||
|
||||
window.swarmAssistent = {
|
||||
setImageFromSrc,
|
||||
putImageOnBoard,
|
||||
|
||||
@@ -17,3 +17,6 @@ window.SA.applyConfigPatchKeys = function (config) {
|
||||
};
|
||||
|
||||
import './app.js';
|
||||
import { attachTraining } from './training.js';
|
||||
|
||||
attachTraining(window.SA);
|
||||
|
||||
+531
@@ -0,0 +1,531 @@
|
||||
/** Swarm Assistent — training tab (dataset, Modelfile, QLoRA). */
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
export function attachTraining(SA) {
|
||||
const state = {
|
||||
ttab: 'dataset',
|
||||
samples: [],
|
||||
hfResults: [],
|
||||
hfSelected: null,
|
||||
hfCheck: null,
|
||||
trainWs: null,
|
||||
polling: null,
|
||||
agentSettings: { enabled: true, auto_link_on_approve: true, heard_quota: 3 },
|
||||
agentLinked: 0,
|
||||
};
|
||||
|
||||
function setAgentHeardStats(linked) {
|
||||
const el = $('sa_agent_heard_stats');
|
||||
if (el) {
|
||||
el.textContent = `Подключено: ${linked ?? state.agentLinked ?? '—'}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAgentHeardSettings() {
|
||||
try {
|
||||
const data = await SA.request('AssistentGetDatasetAgentSettings', {});
|
||||
const s = data?.settings || {};
|
||||
state.agentSettings = {
|
||||
enabled: s.enabled !== false,
|
||||
auto_link_on_approve: s.auto_link_on_approve !== false,
|
||||
heard_quota: s.heard_quota ?? 3,
|
||||
};
|
||||
state.agentLinked = data?.linked ?? 0;
|
||||
if ($('sa_agent_heard_enabled')) $('sa_agent_heard_enabled').checked = state.agentSettings.enabled;
|
||||
if ($('sa_agent_auto_link')) $('sa_agent_auto_link').checked = state.agentSettings.auto_link_on_approve;
|
||||
if ($('sa_agent_heard_quota')) $('sa_agent_heard_quota').value = String(state.agentSettings.heard_quota);
|
||||
setAgentHeardStats(state.agentLinked);
|
||||
} catch (e) {
|
||||
console.warn('loadAgentHeardSettings', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAgentHeardSettings() {
|
||||
const settings = {
|
||||
enabled: !!$('sa_agent_heard_enabled')?.checked,
|
||||
auto_link_on_approve: !!$('sa_agent_auto_link')?.checked,
|
||||
heard_quota: Math.max(0, Math.min(8, parseInt($('sa_agent_heard_quota')?.value, 10) || 3)),
|
||||
};
|
||||
try {
|
||||
const data = await SA.request('AssistentSaveDatasetAgentSettings', { settings });
|
||||
state.agentSettings = data?.settings || settings;
|
||||
setTrainStatus('Настройки «услышанного» сохранены');
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAllToAgent() {
|
||||
setTrainStatus('Подключение к агенту…');
|
||||
try {
|
||||
await saveAgentHeardSettings();
|
||||
const data = await SA.request('AssistentSyncDatasetToAgent', { approved_only: true, relink: false });
|
||||
state.agentLinked = data?.total_linked ?? state.agentLinked;
|
||||
setAgentHeardStats(state.agentLinked);
|
||||
setTrainStatus(`Подключено: +${data?.linked_now ?? 0}, всего ${data?.total_linked ?? '—'}`);
|
||||
await refreshSamples();
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function setTrainStatus(msg) {
|
||||
const el = $('sa_train_status');
|
||||
if (el) el.textContent = msg || '';
|
||||
}
|
||||
|
||||
function setTrainingTab(id) {
|
||||
state.ttab = id || 'dataset';
|
||||
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
|
||||
const on = btn.getAttribute('data-ttab') === state.ttab;
|
||||
btn.classList.toggle('sa-ttab-active', on);
|
||||
btn.setAttribute('aria-selected', on ? 'true' : 'false');
|
||||
});
|
||||
document.querySelectorAll('#sa_training .sa-tpane').forEach((pane) => {
|
||||
pane.hidden = pane.getAttribute('data-tpane') !== state.ttab;
|
||||
});
|
||||
if (state.ttab === 'dataset') {
|
||||
refreshSamples();
|
||||
loadAgentHeardSettings();
|
||||
}
|
||||
if (state.ttab === 'train') syncModelfileModels();
|
||||
if (state.ttab === 'models') refreshTrainModels();
|
||||
}
|
||||
|
||||
async function refreshSamples() {
|
||||
try {
|
||||
const status = $('sa_train_filter_status')?.value || 'all';
|
||||
const persona = $('sa_train_filter_persona')?.value || 'all';
|
||||
const data = await SA.request('AssistentListTrainSamples', { status, persona, limit: 300 });
|
||||
state.samples = data?.samples || [];
|
||||
const stats = $('sa_train_stats');
|
||||
if (stats) stats.textContent = `Одобрено: ${data?.approved ?? '—'} · всего: ${data?.total ?? '—'}`;
|
||||
const personaSel = $('sa_train_filter_persona');
|
||||
if (personaSel && $('sa_persona')) {
|
||||
const cur = personaSel.value || 'all';
|
||||
personaSel.innerHTML = '<option value="all">Все личности</option>';
|
||||
for (const opt of $('sa_persona').options) {
|
||||
const o = document.createElement('option');
|
||||
o.value = opt.value;
|
||||
o.textContent = opt.textContent;
|
||||
personaSel.appendChild(o);
|
||||
}
|
||||
personaSel.value = cur;
|
||||
}
|
||||
renderSamples();
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSamples() {
|
||||
const root = $('sa_train_samples');
|
||||
if (!root) return;
|
||||
if (!state.samples.length) {
|
||||
root.innerHTML = '<div class="sa-mem-empty">Нет примеров. Отметь ответы в чате или импортируй датасет.</div>';
|
||||
return;
|
||||
}
|
||||
root.innerHTML = '';
|
||||
for (const s of state.samples) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'sa-train-sample';
|
||||
div.dataset.id = s.id;
|
||||
const msgs = s.messages || [];
|
||||
const preview = msgs.map((m) => `${m.role}: ${(m.content || '').slice(0, 120)}`).join('\n');
|
||||
const linked = s.agent_linked ? ' · 🔗 агент' : '';
|
||||
div.innerHTML = `
|
||||
<div class="sa-train-sample-head">
|
||||
<span class="sa-hf-badge sa-hf-badge-${s.status === 'approved' ? 'ok' : s.status === 'rejected' ? 'no' : 'map'}">${escapeHtml(s.status)}</span>
|
||||
<span>${escapeHtml(s.source)} · ${escapeHtml(s.persona || '—')} · ${escapeHtml(s.pack || '—')}${linked}</span>
|
||||
<button type="button" class="basic-button" data-approve="1">✓</button>
|
||||
<button type="button" class="basic-button" data-reject="1">✕</button>
|
||||
<button type="button" class="basic-button" data-link="1" title="Подключить к агенту">🔗</button>
|
||||
<button type="button" class="basic-button" data-unlink="1" title="Отключить от агента">⛓</button>
|
||||
<button type="button" class="basic-button sa-danger-btn" data-del="1">Удалить</button>
|
||||
</div>
|
||||
<textarea spellcheck="false">${escapeHtml(preview)}</textarea>`;
|
||||
root.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertSample(patch) {
|
||||
await SA.request('AssistentUpsertTrainSample', patch);
|
||||
await refreshSamples();
|
||||
}
|
||||
|
||||
function renderHfList() {
|
||||
const root = $('sa_hf_list');
|
||||
if (!root) return;
|
||||
root.innerHTML = '';
|
||||
const showAll = !!$('sa_hf_show_all')?.checked;
|
||||
for (const r of state.hfResults) {
|
||||
if (!showAll && r.gate === 'rejected') continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'sa-hf-row' + (state.hfSelected === r.id ? ' sa-hf-row-active' : '') + (r.gate === 'rejected' ? ' sa-hf-rejected' : '');
|
||||
row.dataset.id = r.id;
|
||||
const badge = r.gate === 'ok' ? 'ok' : r.gate === 'mapping' ? 'map' : 'no';
|
||||
row.innerHTML = `<span class="sa-hf-badge sa-hf-badge-${badge}">${escapeHtml(r.gate)}</span><strong>${escapeHtml(r.id)}</strong><span>${escapeHtml(r.reason || '')}</span>`;
|
||||
root.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchHf() {
|
||||
const q = ($('sa_hf_search')?.value || '').trim();
|
||||
setTrainStatus('Поиск…');
|
||||
try {
|
||||
const data = await SA.request('AssistentSearchHfDatasets', {
|
||||
q,
|
||||
limit: 24,
|
||||
show_all: !!$('sa_hf_show_all')?.checked,
|
||||
});
|
||||
state.hfResults = data?.results || [];
|
||||
renderHfList();
|
||||
setTrainStatus(`Найдено: ${state.hfResults.length}`);
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHfLink() {
|
||||
const link = ($('sa_hf_link')?.value || '').trim();
|
||||
const status = $('sa_hf_status');
|
||||
if (!link) return;
|
||||
if (status) status.textContent = 'Проверяю…';
|
||||
try {
|
||||
const data = await SA.request('AssistentCheckHfDataset', { dataset: link });
|
||||
state.hfCheck = data;
|
||||
state.hfSelected = data.id;
|
||||
if (status) {
|
||||
status.textContent = data.gate === 'rejected'
|
||||
? `Отклонено: ${data.reason}`
|
||||
: `${data.gate}: ${data.reason || 'OK'}`;
|
||||
}
|
||||
const preview = $('sa_hf_preview');
|
||||
if (preview) {
|
||||
preview.hidden = false;
|
||||
preview.textContent = JSON.stringify(data.sample_rows || data.features || data, null, 2).slice(0, 8000);
|
||||
}
|
||||
const importRow = $('sa_hf_import_row');
|
||||
if (importRow) importRow.hidden = data.gate === 'rejected';
|
||||
} catch (e) {
|
||||
if (status) status.textContent = String(e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
async function importHf() {
|
||||
if (!state.hfSelected && !state.hfCheck?.id) {
|
||||
setTrainStatus('Сначала проверь набор');
|
||||
return;
|
||||
}
|
||||
const id = state.hfSelected || state.hfCheck.id;
|
||||
const limit = Number($('sa_hf_import_limit')?.value) || 200;
|
||||
try {
|
||||
const data = await SA.request('AssistentImportHfDataset', { dataset: id, limit });
|
||||
setTrainStatus(`Импортировано: ${data.imported}${data.runner_only ? ' (runner-only)' : ''}`);
|
||||
await refreshSamples();
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function syncModelfileModels() {
|
||||
try {
|
||||
const baseUrl = $('sa_base_url')?.value || localStorage.getItem('swarm_assistent_base_url') || '';
|
||||
const data = await SA.request('AssistentListModels', { baseUrl });
|
||||
const models = data?.models || [];
|
||||
for (const selId of ['sa_modelfile_base']) {
|
||||
const sel = $(selId);
|
||||
if (!sel) continue;
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">—</option>';
|
||||
for (const m of models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m;
|
||||
opt.textContent = m;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (cur) sel.value = cur;
|
||||
}
|
||||
const personaSel = $('sa_modelfile_persona');
|
||||
if (personaSel && $('sa_persona')) {
|
||||
personaSel.innerHTML = $('sa_persona').innerHTML;
|
||||
personaSel.value = $('sa_persona').value || 'neutral';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function createModelfile() {
|
||||
setTrainStatus('Создаю модель…');
|
||||
try {
|
||||
const data = await SA.request('AssistentCreateOllamaModel', {
|
||||
base_url: $('sa_base_url')?.value,
|
||||
base_model: $('sa_modelfile_base')?.value,
|
||||
name: $('sa_modelfile_name')?.value,
|
||||
persona: $('sa_modelfile_persona')?.value,
|
||||
system: $('sa_modelfile_system')?.value,
|
||||
shots: Number($('sa_modelfile_shots')?.value) || 8,
|
||||
num_ctx: Number($('sa_modelfile_num_ctx')?.value) || 16384,
|
||||
temperature: Number($('sa_modelfile_temp')?.value) || 0.7,
|
||||
});
|
||||
setTrainStatus(`Готово: ${data.name}`);
|
||||
SA.app?.refreshModels?.();
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
function setTrainMode(mode) {
|
||||
$('sa_train_form_modelfile').hidden = mode !== 'modelfile';
|
||||
$('sa_train_form_qlora').hidden = mode !== 'qlora';
|
||||
}
|
||||
|
||||
function setTrainingLock(on, text) {
|
||||
const root = $('swarm_assistent_root');
|
||||
const banner = $('sa_train_banner');
|
||||
if (root) root.classList.toggle('sa-root-training-lock', !!on);
|
||||
if (banner) {
|
||||
banner.hidden = !on;
|
||||
const t = $('sa_train_banner_text');
|
||||
if (t && text) t.textContent = text;
|
||||
}
|
||||
SA.app?.setTrainingLock?.(!!on);
|
||||
}
|
||||
|
||||
async function pollTrainJob() {
|
||||
try {
|
||||
const data = await SA.request('AssistentGetTrainJob', {});
|
||||
const prog = data?.job?.progress_json ? JSON.parse(data.job.progress_json) : null;
|
||||
const active = data?.training_active || data?.job?.status === 'running';
|
||||
setTrainingLock(active, prog?.status === 'running' ? `Тренировка · ${prog?.percent ?? 0}%` : 'Идёт тренировка…');
|
||||
const logEl = $('sa_train_log');
|
||||
const bar = $('sa_train_progress_fill');
|
||||
const box = $('sa_train_progress');
|
||||
if (prog) {
|
||||
if (box) box.hidden = false;
|
||||
if (bar && prog.percent != null) bar.style.width = `${prog.percent}%`;
|
||||
if (logEl && prog.log) logEl.textContent = prog.log;
|
||||
}
|
||||
if (!active) {
|
||||
clearInterval(state.polling);
|
||||
state.polling = null;
|
||||
$('sa_btn_qlora_cancel').hidden = true;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function startQlora() {
|
||||
setTrainStatus('Запуск…');
|
||||
try {
|
||||
await SA.request('AssistentStartTrainJob', {
|
||||
base_url: $('sa_base_url')?.value,
|
||||
chat_model: $('sa_model')?.value,
|
||||
base_model: $('sa_qlora_base')?.value,
|
||||
output_name: $('sa_qlora_name')?.value,
|
||||
rank: Number($('sa_qlora_rank')?.value) || 16,
|
||||
alpha: Number($('sa_qlora_alpha')?.value) || 32,
|
||||
lr: Number($('sa_qlora_lr')?.value) || 0.0002,
|
||||
epochs: Number($('sa_qlora_epochs')?.value) || 3,
|
||||
seq_len: Number($('sa_qlora_seq')?.value) || 2048,
|
||||
four_bit: !!$('sa_qlora_4bit')?.checked,
|
||||
hf_dataset: ($('sa_qlora_hf_dataset')?.value || '').trim() || undefined,
|
||||
});
|
||||
$('sa_btn_qlora_cancel').hidden = false;
|
||||
setTrainingLock(true, 'Идёт тренировка…');
|
||||
if (state.polling) clearInterval(state.polling);
|
||||
state.polling = setInterval(pollTrainJob, 1500);
|
||||
pollTrainJob();
|
||||
setTrainStatus('Тренировка запущена');
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelQlora() {
|
||||
try {
|
||||
await SA.request('AssistentCancelTrainJob', {});
|
||||
setTrainingLock(false);
|
||||
setTrainStatus('Отменено');
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTrainModels() {
|
||||
const root = $('sa_train_models_list');
|
||||
if (!root) return;
|
||||
try {
|
||||
const data = await SA.request('AssistentListModels', { baseUrl: $('sa_base_url')?.value });
|
||||
const models = data?.models || [];
|
||||
root.innerHTML = models.length
|
||||
? models.map((m) => `<div class="sa-hf-row"><strong>${escapeHtml(m)}</strong></div>`).join('')
|
||||
: '<div class="sa-mem-empty">Нет моделей</div>';
|
||||
} catch (e) {
|
||||
root.innerHTML = `<div class="sa-mem-empty">${escapeHtml(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRunner() {
|
||||
try {
|
||||
await SA.request('AssistentSaveRunnerSettings', {
|
||||
python: $('sa_runner_python')?.value,
|
||||
kind: $('sa_runner_kind')?.value,
|
||||
workdir: $('sa_runner_workdir')?.value,
|
||||
cmd: $('sa_runner_cmd')?.value,
|
||||
gguf_script: $('sa_runner_gguf_script')?.value,
|
||||
});
|
||||
setTrainStatus('Раннер сохранён');
|
||||
} catch (e) {
|
||||
setTrainStatus(String(e.message || e));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRunner() {
|
||||
try {
|
||||
const data = await SA.request('AssistentGetRunnerSettings', {});
|
||||
const s = data?.settings || {};
|
||||
if ($('sa_runner_python') && s.python) $('sa_runner_python').value = s.python;
|
||||
if ($('sa_runner_kind') && s.kind) $('sa_runner_kind').value = s.kind;
|
||||
if ($('sa_runner_workdir') && s.workdir) $('sa_runner_workdir').value = s.workdir;
|
||||
if ($('sa_runner_cmd') && s.cmd) $('sa_runner_cmd').value = s.cmd;
|
||||
if ($('sa_runner_gguf_script') && s.gguf_script) $('sa_runner_gguf_script').value = s.gguf_script;
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function wireTraining() {
|
||||
if (window.__saTrainingWired) return;
|
||||
window.__saTrainingWired = true;
|
||||
document.querySelectorAll('#sa_training .sa-ttab').forEach((btn) => {
|
||||
btn.addEventListener('click', () => setTrainingTab(btn.getAttribute('data-ttab')));
|
||||
});
|
||||
$('sa_btn_agent_sync')?.addEventListener('click', syncAllToAgent);
|
||||
$('sa_agent_heard_enabled')?.addEventListener('change', saveAgentHeardSettings);
|
||||
$('sa_agent_auto_link')?.addEventListener('change', saveAgentHeardSettings);
|
||||
$('sa_agent_heard_quota')?.addEventListener('change', saveAgentHeardSettings);
|
||||
loadAgentHeardSettings();
|
||||
$('sa_btn_train_from_chats')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await SA.request('AssistentBuildDatasetFromChats', {});
|
||||
setTrainStatus(`Из чатов: +${data.added}`);
|
||||
await refreshSamples();
|
||||
} catch (e) { setTrainStatus(String(e.message || e)); }
|
||||
});
|
||||
$('sa_btn_train_import_file')?.addEventListener('click', () => $('sa_train_import_file')?.click());
|
||||
$('sa_train_import_file')?.addEventListener('change', async (e) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
try {
|
||||
const data = await SA.request('AssistentImportDataset', { format: 'auto', content: text });
|
||||
setTrainStatus(`Импорт: ${data.imported}`);
|
||||
await refreshSamples();
|
||||
} catch (err) { setTrainStatus(String(err.message || err)); }
|
||||
e.target.value = '';
|
||||
});
|
||||
$('sa_btn_train_export')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await SA.request('AssistentExportDataset', { status: 'approved' });
|
||||
if (data.content) {
|
||||
const blob = new Blob([data.content], { type: 'application/jsonl' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'assistent-dataset.jsonl';
|
||||
a.click();
|
||||
}
|
||||
setTrainStatus(`Экспорт: ${data.count} примеров`);
|
||||
} catch (e) { setTrainStatus(String(e.message || e)); }
|
||||
});
|
||||
$('sa_train_filter_status')?.addEventListener('change', refreshSamples);
|
||||
$('sa_train_filter_persona')?.addEventListener('change', refreshSamples);
|
||||
$('sa_train_samples')?.addEventListener('click', async (e) => {
|
||||
const row = e.target.closest('.sa-train-sample');
|
||||
if (!row) return;
|
||||
const id = row.dataset.id;
|
||||
const sample = state.samples.find((s) => s.id === id);
|
||||
if (!sample) return;
|
||||
if (e.target.closest('[data-approve]')) {
|
||||
await upsertSample({ ...sample, status: 'approved' });
|
||||
await loadAgentHeardSettings();
|
||||
} else if (e.target.closest('[data-reject]')) {
|
||||
await upsertSample({ ...sample, status: 'rejected' });
|
||||
await loadAgentHeardSettings();
|
||||
} else if (e.target.closest('[data-link]')) {
|
||||
try {
|
||||
const data = await SA.request('AssistentLinkTrainSampleToAgent', { id });
|
||||
state.agentLinked = data?.linked ?? state.agentLinked;
|
||||
setAgentHeardStats(state.agentLinked);
|
||||
setTrainStatus('Пример подключён к агенту');
|
||||
await refreshSamples();
|
||||
} catch (err) { setTrainStatus(String(err.message || err)); }
|
||||
} else if (e.target.closest('[data-unlink]')) {
|
||||
try {
|
||||
const data = await SA.request('AssistentUnlinkTrainSampleFromAgent', { id });
|
||||
state.agentLinked = data?.linked ?? state.agentLinked;
|
||||
setAgentHeardStats(state.agentLinked);
|
||||
setTrainStatus('Пример отключён от агента');
|
||||
await refreshSamples();
|
||||
} catch (err) { setTrainStatus(String(err.message || err)); }
|
||||
} else if (e.target.closest('[data-del]')) {
|
||||
if (window.confirm('Удалить пример?')) {
|
||||
await SA.request('AssistentDeleteTrainSample', { id });
|
||||
await refreshSamples();
|
||||
}
|
||||
}
|
||||
});
|
||||
$('sa_btn_hf_search')?.addEventListener('click', searchHf);
|
||||
$('sa_hf_show_all')?.addEventListener('change', () => { renderHfList(); });
|
||||
$('sa_hf_list')?.addEventListener('click', async (e) => {
|
||||
const row = e.target.closest('.sa-hf-row');
|
||||
if (!row || row.classList.contains('sa-hf-rejected')) return;
|
||||
state.hfSelected = row.dataset.id;
|
||||
$('sa_hf_link').value = row.dataset.id;
|
||||
renderHfList();
|
||||
await checkHfLink();
|
||||
});
|
||||
$('sa_btn_hf_check')?.addEventListener('click', checkHfLink);
|
||||
$('sa_btn_hf_import')?.addEventListener('click', importHf);
|
||||
document.querySelectorAll('input[name="sa_train_mode"]').forEach((r) => {
|
||||
r.addEventListener('change', () => setTrainMode(r.value));
|
||||
});
|
||||
$('sa_btn_modelfile_create')?.addEventListener('click', createModelfile);
|
||||
$('sa_btn_qlora_start')?.addEventListener('click', startQlora);
|
||||
$('sa_btn_qlora_cancel')?.addEventListener('click', cancelQlora);
|
||||
$('sa_btn_train_models_refresh')?.addEventListener('click', refreshTrainModels);
|
||||
$('sa_btn_save_runner')?.addEventListener('click', saveRunner);
|
||||
loadRunner();
|
||||
setTrainMode('modelfile');
|
||||
}
|
||||
|
||||
SA.training = {
|
||||
render() {
|
||||
wireTraining();
|
||||
setTrainingTab(state.ttab);
|
||||
},
|
||||
async curateFromChat(messages, meta) {
|
||||
try {
|
||||
await SA.request('AssistentUpsertTrainSample', {
|
||||
source: 'chat',
|
||||
chat_id: meta?.chatId,
|
||||
persona: meta?.persona,
|
||||
pack: meta?.pack,
|
||||
status: meta?.status || 'approved',
|
||||
messages,
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.warn('curateFromChat', e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
setTrainingLock,
|
||||
pollTrainJob,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user