Ship Assistent 0.8.1: split modules and shared+personal vector memory.
Personal RAG never leaks into the shared store; retrieve merges shared plus the persona chain, with personal overwrite on kind+key. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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>Disk persistence for chat sessions and UI state under <c>DataRoot()/Assistent/</c>.
|
||||
/// Chats survive browser storage wipes and follow the data volume across gpu-rent VMs.</summary>
|
||||
public partial class SwarmAssistentExtension
|
||||
{
|
||||
const int MaxChatsOnDisk = 60;
|
||||
const int MaxChatMessagesOnDisk = 40;
|
||||
const int MaxChatMessageChars = 4000;
|
||||
|
||||
static readonly Regex ChatIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>UI-state keys accepted from the browser — anything else is dropped.</summary>
|
||||
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",
|
||||
];
|
||||
|
||||
string AssistentDataDir() => Path.Combine(DataRoot(), "Assistent");
|
||||
|
||||
string AssistentChatsDir() => Path.Combine(AssistentDataDir(), "chats");
|
||||
|
||||
string AssistentUiStatePath() => Path.Combine(AssistentDataDir(), "ui-state.json");
|
||||
|
||||
static string SafeChatId(string id)
|
||||
{
|
||||
string s = (id ?? "").Trim();
|
||||
return ChatIdRe.IsMatch(s) ? s : null;
|
||||
}
|
||||
|
||||
string ChatFilePath(string id)
|
||||
{
|
||||
string safe = SafeChatId(id);
|
||||
return safe is null ? null : Path.Combine(AssistentChatsDir(), $"{safe}.json");
|
||||
}
|
||||
|
||||
JObject ReadChatFile(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
JObject chat = JObject.Parse(File.ReadAllText(path, Encoding.UTF8));
|
||||
string id = SafeChatId(chat["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(path));
|
||||
if (id is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
chat["id"] = id;
|
||||
return chat;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentPersist read {path}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static JObject ChatSummary(JObject chat)
|
||||
{
|
||||
JArray messages = chat["messages"] as JArray ?? [];
|
||||
return new JObject
|
||||
{
|
||||
["id"] = chat["id"],
|
||||
["title"] = chat["title"]?.ToString() ?? "Новый чат",
|
||||
["createdAt"] = chat["createdAt"] ?? 0,
|
||||
["updatedAt"] = chat["updatedAt"] ?? 0,
|
||||
["messages_count"] = messages.Count,
|
||||
["params"] = chat["params"],
|
||||
};
|
||||
}
|
||||
|
||||
List<JObject> LoadAllChats()
|
||||
{
|
||||
string dir = AssistentChatsDir();
|
||||
if (!Directory.Exists(dir))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
List<JObject> chats = [];
|
||||
foreach (string file in Directory.EnumerateFiles(dir, "*.json"))
|
||||
{
|
||||
JObject chat = ReadChatFile(file);
|
||||
if (chat is not null)
|
||||
{
|
||||
chats.Add(chat);
|
||||
}
|
||||
}
|
||||
return chats
|
||||
.OrderByDescending(c => c["updatedAt"]?.Value<long?>() ?? 0)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>All chats on disk, newest first. Pass <c>with_messages</c> to get full transcripts.</summary>
|
||||
public async Task<JObject> AssistentListChats(Session session, bool with_messages = false, int limit = MaxChatsOnDisk)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
List<JObject> chats = LoadAllChats();
|
||||
int take = Math.Clamp(limit, 1, MaxChatsOnDisk);
|
||||
JArray list = [];
|
||||
foreach (JObject chat in chats.Take(take))
|
||||
{
|
||||
list.Add(with_messages ? chat : ChatSummary(chat));
|
||||
}
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["chats"] = list,
|
||||
["total"] = chats.Count,
|
||||
["path"] = AssistentChatsDir(),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = $"chats list: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetChat(Session session, string id)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string path = ChatFilePath(id);
|
||||
if (path is null)
|
||||
{
|
||||
return new JObject { ["error"] = "valid id required" };
|
||||
}
|
||||
JObject chat = ReadChatFile(path);
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["id"] = SafeChatId(id),
|
||||
["found"] = chat is not null,
|
||||
["chat"] = chat,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Writes one chat to <c>Assistent/chats/<id>.json</c>.
|
||||
/// SwarmUI hands the whole request body to a JObject param, so <c>messages</c> (array)
|
||||
/// and <c>params</c> (object) are read out of <paramref name="raw"/>.</summary>
|
||||
public async Task<JObject> AssistentSaveChat(Session session, string id, string title, JObject raw)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string safe = SafeChatId(id);
|
||||
if (safe is null)
|
||||
{
|
||||
return new JObject { ["error"] = "valid id required" };
|
||||
}
|
||||
JArray messages = raw?["messages"] as JArray ?? [];
|
||||
JObject chatParams = raw?["params"] as JObject;
|
||||
JArray trimmed = [];
|
||||
foreach (JToken msg in messages.Skip(Math.Max(0, messages.Count - MaxChatMessagesOnDisk)))
|
||||
{
|
||||
if (msg is not JObject mo)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
JObject copy = new()
|
||||
{
|
||||
["role"] = mo["role"]?.ToString() ?? "user",
|
||||
["content"] = Clip(mo["content"]?.ToString() ?? "", MaxChatMessageChars),
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(mo["persona"]?.ToString()))
|
||||
{
|
||||
copy["persona"] = mo["persona"];
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(mo["pack"]?.ToString()))
|
||||
{
|
||||
copy["pack"] = mo["pack"];
|
||||
}
|
||||
trimmed.Add(copy);
|
||||
}
|
||||
|
||||
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
string path = ChatFilePath(safe);
|
||||
JObject existing = ReadChatFile(path);
|
||||
JObject chat = new()
|
||||
{
|
||||
["id"] = safe,
|
||||
["title"] = string.IsNullOrWhiteSpace(title) ? (existing?["title"]?.ToString() ?? "Новый чат") : title.Trim(),
|
||||
["createdAt"] = raw?["createdAt"]?.Value<long?>() ?? existing?["createdAt"]?.Value<long?>() ?? now,
|
||||
["updatedAt"] = raw?["updatedAt"]?.Value<long?>() ?? now,
|
||||
["messages"] = trimmed,
|
||||
["params"] = chatParams ?? existing?["params"],
|
||||
};
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AssistentChatsDir());
|
||||
File.WriteAllText(path, chat.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
PruneChatsOnDisk();
|
||||
return new JObject { ["success"] = true, ["id"] = safe, ["path"] = path };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = $"chat save: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentDeleteChat(Session session, string id)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string path = ChatFilePath(id);
|
||||
if (path is null)
|
||||
{
|
||||
return new JObject { ["error"] = "valid id required" };
|
||||
}
|
||||
try
|
||||
{
|
||||
bool existed = File.Exists(path);
|
||||
if (existed)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
return new JObject { ["success"] = true, ["deleted"] = existed, ["id"] = SafeChatId(id) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = $"chat delete: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
void PruneChatsOnDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<JObject> chats = LoadAllChats();
|
||||
if (chats.Count <= MaxChatsOnDisk)
|
||||
{
|
||||
return;
|
||||
}
|
||||
foreach (JObject stale in chats.Skip(MaxChatsOnDisk))
|
||||
{
|
||||
string path = ChatFilePath(stale["id"]?.ToString());
|
||||
if (path is not null && File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logs.Debug($"AssistentPersist prune: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<JObject> AssistentGetUiState(Session session)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
string path = AssistentUiStatePath();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new JObject { ["success"] = true, ["ui_state"] = null };
|
||||
}
|
||||
try
|
||||
{
|
||||
return new JObject
|
||||
{
|
||||
["success"] = true,
|
||||
["ui_state"] = JObject.Parse(File.ReadAllText(path, Encoding.UTF8)),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = $"ui-state.json: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persists the whitelisted UI preferences. Reads <c>ui_state</c> from the body,
|
||||
/// falling back to the flat body for convenience.</summary>
|
||||
public async Task<JObject> AssistentSaveUiState(Session session, JObject raw)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
JObject source = raw?["ui_state"] as JObject ?? raw;
|
||||
if (source is null)
|
||||
{
|
||||
return new JObject { ["error"] = "ui_state required" };
|
||||
}
|
||||
JObject clean = new();
|
||||
foreach (string key in UiStateKeys)
|
||||
{
|
||||
JToken value = source[key];
|
||||
if (value is null || value.Type == JTokenType.Null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
clean[key] = value.DeepClone();
|
||||
}
|
||||
if (clean.Count == 0)
|
||||
{
|
||||
return new JObject { ["error"] = "ui_state has no known keys" };
|
||||
}
|
||||
clean["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AssistentDataDir());
|
||||
string path = AssistentUiStatePath();
|
||||
File.WriteAllText(path, clean.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
|
||||
return new JObject { ["success"] = true, ["path"] = path };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new JObject { ["error"] = $"ui-state save: {ex.Message}" };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user