Store Assistent chats, UI state, and taste in sqlite.

Runtime session data belongs in one DB so History can FTS-search and follow the volume; persona overlays and sidecar cards stay files.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 01:10:57 +03:00
co-authored by Cursor
parent 8702b64e12
commit ac8e30c7c8
10 changed files with 582 additions and 155 deletions
+17
View File
@@ -427,6 +427,23 @@
letter-spacing: 0.03em;
}
.sa-chats-search {
appearance: none;
width: 100%;
box-sizing: border-box;
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
background: color-mix(in srgb, #000 28%, transparent);
color: inherit;
border-radius: 0.35rem;
padding: 0.35rem 0.5rem;
font: inherit;
font-size: 0.82rem;
}
.sa-chats-search:focus {
outline: 1px solid color-mix(in srgb, #6cf 55%, currentColor);
}
.sa-chats-panel-hint {
font-size: 0.72rem;
opacity: 0.65;
+2 -2
View File
@@ -4047,7 +4047,7 @@
}
/**
* Fills fields the browser has never seen from Assistent/ui-state.json, so a fresh
* Fills fields the browser has never seen from sqlite ui_state, so a fresh
* browser on the same volume inherits the previous session. Existing localStorage wins.
* auto_download is only ever restored when it is off the danger flag stays opt-in.
*/
@@ -5361,7 +5361,7 @@
}
const remoteUpdated = remote.updated || 0;
const localUpdated = state.taste?.updated || 0;
// Disk taste.json is the source of truth; localStorage only wins when it is strictly newer.
// sqlite taste is the source of truth; localStorage only wins when it is strictly newer.
const localEmpty = !localUpdated
&& !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
if (localEmpty || remoteUpdated >= localUpdated) {
+13 -2
View File
@@ -1,5 +1,5 @@
/**
* Swarm Assistent — disk persistence for chats + UI state (Assistent/chats/, Assistent/ui-state.json).
* Swarm Assistent — sqlite persistence for chats + UI state (Assistent/memory/assistent.sqlite).
* Loaded after assistent.api.js and before assistent.js.
*/
window.SA = window.SA || {};
@@ -40,6 +40,7 @@ window.SA = window.SA || {};
createdAt: Number(raw.createdAt) || Date.now(),
updatedAt: Number(raw.updatedAt) || Number(raw.createdAt) || Date.now(),
messages: Array.isArray(raw.messages) ? raw.messages : [],
messages_count: Number(raw.messages_count) || (Array.isArray(raw.messages) ? raw.messages.length : 0),
params: raw.params && typeof raw.params === 'object' ? raw.params : null,
};
}
@@ -74,7 +75,7 @@ window.SA = window.SA || {};
return local;
}
/** Disk chats, newest first. Falls back to a localStorage migration when the volume is empty. */
/** Sqlite chats, newest first. Falls back to a localStorage migration when the store is empty. */
async function loadChats() {
let chats = [];
try {
@@ -103,6 +104,15 @@ window.SA = window.SA || {};
return normalizeChat(data?.chat);
}
async function searchChats(q) {
const query = String(q || '').trim();
if (query.length < 2) {
return [];
}
const data = await request('AssistentListChats', { q: query, with_messages: false, limit: 40 });
return (data?.chats || []).map(normalizeChat).filter(Boolean);
}
function saveChat(chat, { immediate = false } = {}) {
const clean = normalizeChat(chat);
if (!clean) {
@@ -183,6 +193,7 @@ window.SA = window.SA || {};
LS_CHATS,
loadChats,
getChat,
searchChats,
saveChat,
deleteChat,
loadUiState,
+485
View File
@@ -0,0 +1,485 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Data.Sqlite;
using Newtonsoft.Json.Linq;
using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent;
/// <summary>Runtime store in the same sqlite file: chats, ui-state, taste.
/// Config overlays, sidecar cards, and ollama-roles stay on disk.</summary>
public sealed partial class AssistentMemory
{
public const int MaxChatsStored = 200;
public const string KvUiState = "ui_state";
public const string KvTaste = "taste";
void EnsureStoreSchema()
{
Exec(
"""
CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chats (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
params_json TEXT,
messages_json TEXT NOT NULL DEFAULT '[]',
messages_count INTEGER NOT NULL DEFAULT 0,
body TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_chats_updated ON chats(updated_at DESC);
""");
EnsureChatsFts();
MigrateJsonStoreOnce();
}
void EnsureChatsFts()
{
try
{
Exec(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS chats_fts USING fts5(
title,
body,
tokenize = 'unicode61 remove_diacritics 2'
);
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS chats_fts_ai AFTER INSERT ON chats BEGIN
INSERT INTO chats_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS chats_fts_ad AFTER DELETE ON chats BEGIN
INSERT INTO chats_fts(chats_fts, rowid) VALUES('delete', old.rowid);
END;
""");
Exec(
"""
CREATE TRIGGER IF NOT EXISTS chats_fts_au AFTER UPDATE ON chats BEGIN
INSERT INTO chats_fts(chats_fts, rowid) VALUES('delete', old.rowid);
INSERT INTO chats_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
END;
""");
int n = 0, fts = 0;
using (SqliteCommand c = _conn.CreateCommand())
{
c.CommandText = "SELECT COUNT(*) FROM chats";
n = Convert.ToInt32(c.ExecuteScalar());
}
using (SqliteCommand c = _conn.CreateCommand())
{
c.CommandText = "SELECT COUNT(*) FROM chats_fts";
fts = Convert.ToInt32(c.ExecuteScalar());
}
if (n != fts)
{
Exec("DELETE FROM chats_fts");
Exec("INSERT INTO chats_fts(rowid, title, body) SELECT rowid, title, body FROM chats");
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory chats FTS5 unavailable: {ex.Message}");
}
}
void MigrateJsonStoreOnce()
{
if (GetMeta("json_store_migrated") == "1")
{
return;
}
string root = Path.Combine(_dataRoot, "Assistent");
int chats = 0;
string chatsDir = Path.Combine(root, "chats");
if (Directory.Exists(chatsDir))
{
foreach (string file in Directory.EnumerateFiles(chatsDir, "*.json"))
{
try
{
JObject chat = JObject.Parse(File.ReadAllText(file, Encoding.UTF8));
string id = (chat["id"]?.ToString() ?? Path.GetFileNameWithoutExtension(file) ?? "").Trim();
if (string.IsNullOrWhiteSpace(id) || GetChatUnlocked(id) is not null)
{
continue;
}
UpsertChatUnlocked(chat, id);
chats++;
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory migrate chat {file}: {ex.Message}");
}
}
}
ImportKvFile(Path.Combine(root, "ui-state.json"), KvUiState);
ImportKvFile(Path.Combine(root, "taste.json"), KvTaste);
SetMeta("json_store_migrated", "1");
TryArchiveMigratedJson(root, chatsDir);
if (chats > 0)
{
Logs.Debug($"AssistentMemory: migrated {chats} chats from JSON into sqlite");
}
}
void ImportKvFile(string path, string key)
{
if (!File.Exists(path) || !string.IsNullOrEmpty(GetKvUnlocked(key)))
{
return;
}
try
{
SetKvUnlocked(key, File.ReadAllText(path, Encoding.UTF8));
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory migrate {key}: {ex.Message}");
}
}
void TryArchiveMigratedJson(string root, string chatsDir)
{
try
{
string dest = Path.Combine(root, "_migrated_json");
Directory.CreateDirectory(dest);
MoveIfExists(Path.Combine(root, "ui-state.json"), Path.Combine(dest, "ui-state.json"));
MoveIfExists(Path.Combine(root, "taste.json"), Path.Combine(dest, "taste.json"));
if (!Directory.Exists(chatsDir))
{
return;
}
string chatsDest = Path.Combine(dest, "chats");
Directory.CreateDirectory(chatsDest);
foreach (string file in Directory.EnumerateFiles(chatsDir, "*.json"))
{
MoveIfExists(file, Path.Combine(chatsDest, Path.GetFileName(file)));
}
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory archive json: {ex.Message}");
}
}
static void MoveIfExists(string src, string dest)
{
if (!File.Exists(src))
{
return;
}
if (File.Exists(dest))
{
File.Delete(src);
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(dest)!);
File.Move(src, dest);
}
public string GetKv(string key)
{
lock (_lock)
{
EnsureOpen();
return GetKvUnlocked(key);
}
}
public JObject GetKvObject(string key)
{
string raw = GetKv(key);
if (string.IsNullOrWhiteSpace(raw))
{
return null;
}
try
{
return JObject.Parse(raw);
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory kv {key}: {ex.Message}");
return null;
}
}
public void SetKvObject(string key, JObject value)
{
SetKv(key, value?.ToString(Newtonsoft.Json.Formatting.None) ?? "");
}
public void SetKv(string key, string value)
{
lock (_lock)
{
EnsureOpen();
SetKvUnlocked(key, value);
}
}
string GetKvUnlocked(string key)
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT value FROM kv WHERE key = $k";
cmd.Parameters.AddWithValue("$k", key ?? "");
return cmd.ExecuteScalar()?.ToString();
}
void SetKvUnlocked(string key, string value)
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
INSERT INTO kv(key, value, updated) VALUES($k, $v, $u)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated = excluded.updated
""";
cmd.Parameters.AddWithValue("$k", key ?? "");
cmd.Parameters.AddWithValue("$v", value ?? "");
cmd.Parameters.AddWithValue("$u", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
cmd.ExecuteNonQuery();
}
public List<JObject> ListChats(bool withMessages, int limit, string query = null)
{
lock (_lock)
{
EnsureOpen();
int take = Math.Clamp(limit, 1, MaxChatsStored);
List<JObject> list = [];
string match = BuildFtsMatch(query);
bool searched = false;
if (!string.IsNullOrWhiteSpace(match) && TableExists("chats_fts"))
{
try
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
SELECT id, title, created_at, updated_at, params_json, messages_json, messages_count
FROM chats
WHERE rowid IN (SELECT rowid FROM chats_fts WHERE chats_fts MATCH $q)
ORDER BY updated_at DESC LIMIT $lim
""";
cmd.Parameters.AddWithValue("$q", match);
cmd.Parameters.AddWithValue("$lim", take);
ReadChats(cmd, withMessages, list);
searched = true;
}
catch (Exception ex)
{
Logs.Debug($"AssistentMemory chats FTS: {ex.Message}");
}
}
if (!searched && !string.IsNullOrWhiteSpace(query))
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
SELECT id, title, created_at, updated_at, params_json, messages_json, messages_count
FROM chats
WHERE title LIKE $like ESCAPE '\' OR body LIKE $like ESCAPE '\'
ORDER BY updated_at DESC LIMIT $lim
""";
cmd.Parameters.AddWithValue("$like", "%" + EscapeLike(query.Trim()) + "%");
cmd.Parameters.AddWithValue("$lim", take);
ReadChats(cmd, withMessages, list);
searched = true;
}
if (!searched)
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
SELECT id, title, created_at, updated_at, params_json, messages_json, messages_count
FROM chats
ORDER BY updated_at DESC LIMIT $lim
""";
cmd.Parameters.AddWithValue("$lim", take);
ReadChats(cmd, withMessages, list);
}
return list;
}
}
public int CountChats()
{
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM chats";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
public JObject GetChat(string id)
{
lock (_lock)
{
EnsureOpen();
return GetChatUnlocked(id);
}
}
public void SaveChat(JObject chat)
{
lock (_lock)
{
EnsureOpen();
string id = chat?["id"]?.ToString()?.Trim();
if (string.IsNullOrWhiteSpace(id))
{
throw new ArgumentException("chat id required");
}
UpsertChatUnlocked(chat, id);
PruneChatsUnlocked();
}
}
public bool DeleteChat(string id)
{
lock (_lock)
{
EnsureOpen();
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "DELETE FROM chats WHERE id = $id";
cmd.Parameters.AddWithValue("$id", (id ?? "").Trim());
return cmd.ExecuteNonQuery() > 0;
}
}
JObject GetChatUnlocked(string id)
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
SELECT id, title, created_at, updated_at, params_json, messages_json, messages_count
FROM chats WHERE id = $id LIMIT 1
""";
cmd.Parameters.AddWithValue("$id", (id ?? "").Trim());
List<JObject> list = [];
ReadChats(cmd, withMessages: true, list);
return list.Count > 0 ? list[0] : null;
}
void UpsertChatUnlocked(JObject chat, string id)
{
JArray messages = chat["messages"] as JArray ?? [];
string title = chat["title"]?.ToString() ?? "Новый чат";
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long created = chat["createdAt"]?.Value<long?>() ?? now;
long updated = chat["updatedAt"]?.Value<long?>() ?? now;
string paramsJson = chat["params"] is JObject p ? p.ToString(Newtonsoft.Json.Formatting.None) : null;
string messagesJson = messages.ToString(Newtonsoft.Json.Formatting.None);
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
INSERT INTO chats(id, title, created_at, updated_at, params_json, messages_json, messages_count, body)
VALUES($id, $title, $created, $updated, $params, $messages, $count, $body)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
params_json = excluded.params_json,
messages_json = excluded.messages_json,
messages_count = excluded.messages_count,
body = excluded.body
""";
cmd.Parameters.AddWithValue("$id", id);
cmd.Parameters.AddWithValue("$title", title);
cmd.Parameters.AddWithValue("$created", created);
cmd.Parameters.AddWithValue("$updated", updated);
cmd.Parameters.AddWithValue("$params", (object)paramsJson ?? DBNull.Value);
cmd.Parameters.AddWithValue("$messages", messagesJson);
cmd.Parameters.AddWithValue("$count", messages.Count);
cmd.Parameters.AddWithValue("$body", ChatFtsBody(title, messages));
cmd.ExecuteNonQuery();
}
void PruneChatsUnlocked()
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText =
"""
DELETE FROM chats WHERE rowid IN (
SELECT rowid FROM chats ORDER BY updated_at DESC LIMIT -1 OFFSET $keep
)
""";
cmd.Parameters.AddWithValue("$keep", MaxChatsStored);
cmd.ExecuteNonQuery();
}
static void ReadChats(SqliteCommand cmd, bool withMessages, List<JObject> list)
{
using SqliteDataReader reader = cmd.ExecuteReader();
while (reader.Read())
{
list.Add(ReadChatRow(reader, withMessages));
}
}
static JObject ReadChatRow(SqliteDataReader reader, bool withMessages)
{
JObject chat = new()
{
["id"] = reader.GetString(0),
["title"] = reader.IsDBNull(1) ? "Новый чат" : reader.GetString(1),
["createdAt"] = reader.GetInt64(2),
["updatedAt"] = reader.GetInt64(3),
["messages_count"] = reader.GetInt32(6),
};
if (!reader.IsDBNull(4))
{
try
{
chat["params"] = JObject.Parse(reader.GetString(4));
}
catch
{
chat["params"] = null;
}
}
if (withMessages)
{
try
{
chat["messages"] = reader.IsDBNull(5) ? new JArray() : JArray.Parse(reader.GetString(5));
}
catch
{
chat["messages"] = new JArray();
}
}
return chat;
}
static string ChatFtsBody(string title, JArray messages)
{
StringBuilder sb = new();
sb.Append(title).Append('\n');
foreach (JToken token in messages ?? [])
{
if (token is JObject mo)
{
sb.Append(mo["content"]?.ToString()).Append('\n');
}
}
return sb.ToString();
}
}
+1 -1
View File
@@ -202,7 +202,7 @@ public sealed partial class AssistentMemory : IDisposable
{
using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "PRAGMA " + pragma;
cmd.ExecuteNonQuery();
_ = cmd.ExecuteScalar();
}
catch (Exception ex)
{
+48 -133
View File
@@ -1,9 +1,6 @@
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;
@@ -11,15 +8,14 @@ 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>
/// <summary>Chat sessions and UI state in <c>Assistent/memory/assistent.sqlite</c>.
/// Survives browser wipes and follows 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);
static readonly System.Text.RegularExpressions.Regex ChatIdRe = new(@"^[A-Za-z0-9][A-Za-z0-9_\-]{0,63}$", System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>UI-state keys accepted from the browser — anything else is dropped.</summary>
static readonly string[] UiStateKeys =
@@ -28,93 +24,35 @@ public partial class SwarmAssistentExtension
"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,
["messages_count"] = chat["messages_count"] ?? (chat["messages"] as JArray)?.Count ?? 0,
["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)
/// <summary>All chats, newest first. <paramref name="q"/> searches title+body via FTS.</summary>
public async Task<JObject> AssistentListChats(Session session, bool with_messages = false, int limit = 60, string q = null)
{
await Task.CompletedTask;
try
{
List<JObject> chats = LoadAllChats();
int take = Math.Clamp(limit, 1, MaxChatsOnDisk);
int take = Math.Clamp(limit, 1, AssistentMemory.MaxChatsStored);
List<JObject> chats = Memory.ListChats(with_messages, take, q);
JArray list = [];
foreach (JObject chat in chats.Take(take))
foreach (JObject chat in chats)
{
list.Add(with_messages ? chat : ChatSummary(chat));
}
@@ -122,8 +60,8 @@ public partial class SwarmAssistentExtension
{
["success"] = true,
["chats"] = list,
["total"] = chats.Count,
["path"] = AssistentChatsDir(),
["total"] = string.IsNullOrWhiteSpace(q) ? Memory.CountChats() : list.Count,
["path"] = "Assistent/memory/assistent.sqlite",
};
}
catch (Exception ex)
@@ -135,22 +73,29 @@ public partial class SwarmAssistentExtension
public async Task<JObject> AssistentGetChat(Session session, string id)
{
await Task.CompletedTask;
string path = ChatFilePath(id);
if (path is null)
string safe = SafeChatId(id);
if (safe is null)
{
return new JObject { ["error"] = "valid id required" };
}
JObject chat = ReadChatFile(path);
return new JObject
try
{
["success"] = true,
["id"] = SafeChatId(id),
["found"] = chat is not null,
["chat"] = chat,
};
JObject chat = Memory.GetChat(safe);
return new JObject
{
["success"] = true,
["id"] = safe,
["found"] = chat is not null,
["chat"] = chat,
};
}
catch (Exception ex)
{
return new JObject { ["error"] = $"chat get: {ex.Message}" };
}
}
/// <summary>Writes one chat to <c>Assistent/chats/&lt;id&gt;.json</c>.
/// <summary>Writes one chat into sqlite.
/// 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)
@@ -187,8 +132,15 @@ public partial class SwarmAssistentExtension
}
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string path = ChatFilePath(safe);
JObject existing = ReadChatFile(path);
JObject existing = null;
try
{
existing = Memory.GetChat(safe);
}
catch (Exception ex)
{
Logs.Debug($"AssistentSaveChat existing: {ex.Message}");
}
JObject chat = new()
{
["id"] = safe,
@@ -196,14 +148,12 @@ public partial class SwarmAssistentExtension
["createdAt"] = raw?["createdAt"]?.Value<long?>() ?? existing?["createdAt"]?.Value<long?>() ?? now,
["updatedAt"] = raw?["updatedAt"]?.Value<long?>() ?? now,
["messages"] = trimmed,
["params"] = chatParams ?? existing?["params"],
["params"] = chatParams ?? existing?["params"] as JObject,
};
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 };
Memory.SaveChat(chat);
return new JObject { ["success"] = true, ["id"] = safe, ["path"] = "Assistent/memory/assistent.sqlite" };
}
catch (Exception ex)
{
@@ -214,19 +164,15 @@ public partial class SwarmAssistentExtension
public async Task<JObject> AssistentDeleteChat(Session session, string id)
{
await Task.CompletedTask;
string path = ChatFilePath(id);
if (path is null)
string safe = SafeChatId(id);
if (safe 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) };
bool existed = Memory.DeleteChat(safe);
return new JObject { ["success"] = true, ["deleted"] = existed, ["id"] = safe };
}
catch (Exception ex)
{
@@ -234,49 +180,20 @@ public partial class SwarmAssistentExtension
}
}
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)),
["ui_state"] = Memory.GetKvObject(AssistentMemory.KvUiState),
};
}
catch (Exception ex)
{
return new JObject { ["error"] = $"ui-state.json: {ex.Message}" };
return new JObject { ["error"] = $"ui-state: {ex.Message}" };
}
}
@@ -307,10 +224,8 @@ public partial class SwarmAssistentExtension
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 };
Memory.SetKvObject(AssistentMemory.KvUiState, clean);
return new JObject { ["success"] = true, ["path"] = "Assistent/memory/assistent.sqlite" };
}
catch (Exception ex)
{
+1 -1
View File
@@ -28,7 +28,7 @@ Omit the tool action on the follow-up turn once you have results.
## When not to write
- Do not dump Exact defaults or the full inventory into vector memory.
- Do not store the user's taste profile (`taste.json`).
- Do not store the user's taste profile (sqlite `kv.taste`).
- Do not upsert trivia already in `memory_hits`.
- Do not upsert Danbooru tags — the csv catalog already has them.
- `memory_forget` without `scope` only removes the personal overlay.
+14 -15
View File
@@ -2,7 +2,7 @@
SwarmUI extension for **collaborative Krea 2** prompting via **Ollama**: chat + board (Generate | Refs tabs), LoRA chips, **persona presets** (`Config/personas/`), **vector memory**, model cards with Civitai fetch, img2img/inpaint, slash commands, auto Generate.
**Version 0.8.2**Hybrid memory (FTS5 + cosine, kind quotas, min_score) plus model hops `memory_get` / `memory_search` / `lookup_tags`. Danbooru csv is a shared FTS catalog (no embeddings); Krea prompts stay prose.
**Version 0.8.3**Chats, UI state and taste live in `assistent.sqlite` (FTS search in History). Memory stays hybrid FTS5 + cosine. Danbooru csv is a shared FTS catalog (no embeddings); Krea prompts stay prose.
## Layout
@@ -24,12 +24,10 @@ Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder l
```
Assistent/
_base/ personas/<id>/ # overlay presets — same names as Config/, sparse
settings.json # embed_model, base_url, per-persona skills
ui-state.json # pack / persona / auto_* / pane_width / models — seeds a fresh browser
taste.json # learned taste profile (wins over localStorage)
chats/<id>.json # chat history, newest 60 kept
ollama-roles.json # chat vs memory model tags
memory/assistent.sqlite # vector store (shared + per-persona)
settings.json # embed_model, base_url, per-persona skills (config overlay)
ollama-roles.json # chat vs memory model tags (gpu-rent writes this)
memory/assistent.sqlite # vector memory + tags FTS + chats + ui_state + taste
_migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json
```
Copy `personas/cinema/``noir/`, edit only differing JSON files. Persona prompt overrides belong in `personas/<id>/` — the old flat `personas.json` is legacy and only read when no overlay folder exists for that id.
@@ -54,12 +52,13 @@ Two layers in `memory/assistent.sqlite` (`persona` column; empty = shared):
- Soft notes only — Exact and the user beat RAG for params
- ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
## Chats on disk
- Every chat is written to `Assistent/chats/<id>.json` (messages + a Generate params snapshot), so History survives a cleared browser and follows the data volume across VMs
- localStorage stays as a fast cache; on first run with an empty `chats/` the old `swarm_assistent_chats_v1` store is migrated up once
- `ui-state.json` seeds a **fresh** browser only — anything already in localStorage wins, and `auto_download` is never restored as on
## Chats and runtime KV
- Every chat (messages + Generate params snapshot) is a row in `assistent.sqlite`, newest **200** kept. History search uses FTS over title + body.
- First launch after 0.8.3 copies `chats/*.json`, `ui-state.json`, and `taste.json` into sqlite, then archives them under `_migrated_json/`.
- localStorage stays as a fast cache; on first run with an empty store the old `swarm_assistent_chats_v1` browser history is migrated up once.
- UI state seeds a **fresh** browser only — anything already in localStorage wins, and `auto_download` is never restored as on
- `settings.json` and persona overlays stay files (layered merge + git). `.assistent.json` cards stay next to weights.
## VRAM handover
- Before every Generate the chat model is unloaded (`keep_alive: 0`) so Krea 2 gets the whole GPU
@@ -132,14 +131,14 @@ Restart / rebuild SwarmUI after clone. gpu-rent: `seed-extensions` + restart.
| `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile |
| `AssistentGetTaste` / `AssistentSaveTaste` | sqlite `kv.taste` |
| `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` / `AssistentChatWS` | Chat (+ hybrid memory + Civitai/tag hops) |
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/<id>.json` |
| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` |
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
| `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` |
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
## License
-1
View File
@@ -1,6 +1,5 @@
using System;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
+1
View File
@@ -50,6 +50,7 @@
</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>