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; 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 { .sa-chats-panel-hint {
font-size: 0.72rem; font-size: 0.72rem;
opacity: 0.65; 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. * 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. * 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 remoteUpdated = remote.updated || 0;
const localUpdated = state.taste?.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 const localEmpty = !localUpdated
&& !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length); && !(state.taste?.styles?.length || state.taste?.likes?.length || state.taste?.avoid?.length);
if (localEmpty || remoteUpdated >= localUpdated) { 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. * Loaded after assistent.api.js and before assistent.js.
*/ */
window.SA = window.SA || {}; window.SA = window.SA || {};
@@ -40,6 +40,7 @@ window.SA = window.SA || {};
createdAt: Number(raw.createdAt) || Date.now(), createdAt: Number(raw.createdAt) || Date.now(),
updatedAt: Number(raw.updatedAt) || Number(raw.createdAt) || Date.now(), updatedAt: Number(raw.updatedAt) || Number(raw.createdAt) || Date.now(),
messages: Array.isArray(raw.messages) ? raw.messages : [], 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, params: raw.params && typeof raw.params === 'object' ? raw.params : null,
}; };
} }
@@ -74,7 +75,7 @@ window.SA = window.SA || {};
return local; 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() { async function loadChats() {
let chats = []; let chats = [];
try { try {
@@ -103,6 +104,15 @@ window.SA = window.SA || {};
return normalizeChat(data?.chat); 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 } = {}) { function saveChat(chat, { immediate = false } = {}) {
const clean = normalizeChat(chat); const clean = normalizeChat(chat);
if (!clean) { if (!clean) {
@@ -183,6 +193,7 @@ window.SA = window.SA || {};
LS_CHATS, LS_CHATS,
loadChats, loadChats,
getChat, getChat,
searchChats,
saveChat, saveChat,
deleteChat, deleteChat,
loadUiState, 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(); using SqliteCommand cmd = _conn.CreateCommand();
cmd.CommandText = "PRAGMA " + pragma; cmd.CommandText = "PRAGMA " + pragma;
cmd.ExecuteNonQuery(); _ = cmd.ExecuteScalar();
} }
catch (Exception ex) catch (Exception ex)
{ {
+43 -128
View File
@@ -1,9 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using SwarmUI.Accounts; using SwarmUI.Accounts;
@@ -11,15 +8,14 @@ using SwarmUI.Utils;
namespace Mrleo1nid.SwarmAssistent; namespace Mrleo1nid.SwarmAssistent;
/// <summary>Disk persistence for chat sessions and UI state under <c>DataRoot()/Assistent/</c>. /// <summary>Chat sessions and UI state in <c>Assistent/memory/assistent.sqlite</c>.
/// Chats survive browser storage wipes and follow the data volume across gpu-rent VMs.</summary> /// Survives browser wipes and follows the data volume across gpu-rent VMs.</summary>
public partial class SwarmAssistentExtension public partial class SwarmAssistentExtension
{ {
const int MaxChatsOnDisk = 60;
const int MaxChatMessagesOnDisk = 40; const int MaxChatMessagesOnDisk = 40;
const int MaxChatMessageChars = 4000; 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> /// <summary>UI-state keys accepted from the browser — anything else is dropped.</summary>
static readonly string[] UiStateKeys = static readonly string[] UiStateKeys =
@@ -28,93 +24,35 @@ public partial class SwarmAssistentExtension
"auto_download", "pane_width", "embed_model", "base_url", "model", "view", "board_tab", "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) static string SafeChatId(string id)
{ {
string s = (id ?? "").Trim(); string s = (id ?? "").Trim();
return ChatIdRe.IsMatch(s) ? s : null; 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) static JObject ChatSummary(JObject chat)
{ {
JArray messages = chat["messages"] as JArray ?? [];
return new JObject return new JObject
{ {
["id"] = chat["id"], ["id"] = chat["id"],
["title"] = chat["title"]?.ToString() ?? "Новый чат", ["title"] = chat["title"]?.ToString() ?? "Новый чат",
["createdAt"] = chat["createdAt"] ?? 0, ["createdAt"] = chat["createdAt"] ?? 0,
["updatedAt"] = chat["updatedAt"] ?? 0, ["updatedAt"] = chat["updatedAt"] ?? 0,
["messages_count"] = messages.Count, ["messages_count"] = chat["messages_count"] ?? (chat["messages"] as JArray)?.Count ?? 0,
["params"] = chat["params"], ["params"] = chat["params"],
}; };
} }
List<JObject> LoadAllChats() /// <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)
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; await Task.CompletedTask;
try try
{ {
List<JObject> chats = LoadAllChats(); int take = Math.Clamp(limit, 1, AssistentMemory.MaxChatsStored);
int take = Math.Clamp(limit, 1, MaxChatsOnDisk); List<JObject> chats = Memory.ListChats(with_messages, take, q);
JArray list = []; JArray list = [];
foreach (JObject chat in chats.Take(take)) foreach (JObject chat in chats)
{ {
list.Add(with_messages ? chat : ChatSummary(chat)); list.Add(with_messages ? chat : ChatSummary(chat));
} }
@@ -122,8 +60,8 @@ public partial class SwarmAssistentExtension
{ {
["success"] = true, ["success"] = true,
["chats"] = list, ["chats"] = list,
["total"] = chats.Count, ["total"] = string.IsNullOrWhiteSpace(q) ? Memory.CountChats() : list.Count,
["path"] = AssistentChatsDir(), ["path"] = "Assistent/memory/assistent.sqlite",
}; };
} }
catch (Exception ex) catch (Exception ex)
@@ -135,22 +73,29 @@ public partial class SwarmAssistentExtension
public async Task<JObject> AssistentGetChat(Session session, string id) public async Task<JObject> AssistentGetChat(Session session, string id)
{ {
await Task.CompletedTask; await Task.CompletedTask;
string path = ChatFilePath(id); string safe = SafeChatId(id);
if (path is null) if (safe is null)
{ {
return new JObject { ["error"] = "valid id required" }; return new JObject { ["error"] = "valid id required" };
} }
JObject chat = ReadChatFile(path); try
{
JObject chat = Memory.GetChat(safe);
return new JObject return new JObject
{ {
["success"] = true, ["success"] = true,
["id"] = SafeChatId(id), ["id"] = safe,
["found"] = chat is not null, ["found"] = chat is not null,
["chat"] = chat, ["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) /// 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> /// 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) 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(); long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
string path = ChatFilePath(safe); JObject existing = null;
JObject existing = ReadChatFile(path); try
{
existing = Memory.GetChat(safe);
}
catch (Exception ex)
{
Logs.Debug($"AssistentSaveChat existing: {ex.Message}");
}
JObject chat = new() JObject chat = new()
{ {
["id"] = safe, ["id"] = safe,
@@ -196,14 +148,12 @@ public partial class SwarmAssistentExtension
["createdAt"] = raw?["createdAt"]?.Value<long?>() ?? existing?["createdAt"]?.Value<long?>() ?? now, ["createdAt"] = raw?["createdAt"]?.Value<long?>() ?? existing?["createdAt"]?.Value<long?>() ?? now,
["updatedAt"] = raw?["updatedAt"]?.Value<long?>() ?? now, ["updatedAt"] = raw?["updatedAt"]?.Value<long?>() ?? now,
["messages"] = trimmed, ["messages"] = trimmed,
["params"] = chatParams ?? existing?["params"], ["params"] = chatParams ?? existing?["params"] as JObject,
}; };
try try
{ {
Directory.CreateDirectory(AssistentChatsDir()); Memory.SaveChat(chat);
File.WriteAllText(path, chat.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8); return new JObject { ["success"] = true, ["id"] = safe, ["path"] = "Assistent/memory/assistent.sqlite" };
PruneChatsOnDisk();
return new JObject { ["success"] = true, ["id"] = safe, ["path"] = path };
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -214,19 +164,15 @@ public partial class SwarmAssistentExtension
public async Task<JObject> AssistentDeleteChat(Session session, string id) public async Task<JObject> AssistentDeleteChat(Session session, string id)
{ {
await Task.CompletedTask; await Task.CompletedTask;
string path = ChatFilePath(id); string safe = SafeChatId(id);
if (path is null) if (safe is null)
{ {
return new JObject { ["error"] = "valid id required" }; return new JObject { ["error"] = "valid id required" };
} }
try try
{ {
bool existed = File.Exists(path); bool existed = Memory.DeleteChat(safe);
if (existed) return new JObject { ["success"] = true, ["deleted"] = existed, ["id"] = safe };
{
File.Delete(path);
}
return new JObject { ["success"] = true, ["deleted"] = existed, ["id"] = SafeChatId(id) };
} }
catch (Exception ex) 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) public async Task<JObject> AssistentGetUiState(Session session)
{ {
await Task.CompletedTask; await Task.CompletedTask;
string path = AssistentUiStatePath();
if (!File.Exists(path))
{
return new JObject { ["success"] = true, ["ui_state"] = null };
}
try try
{ {
return new JObject return new JObject
{ {
["success"] = true, ["success"] = true,
["ui_state"] = JObject.Parse(File.ReadAllText(path, Encoding.UTF8)), ["ui_state"] = Memory.GetKvObject(AssistentMemory.KvUiState),
}; };
} }
catch (Exception ex) 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(); clean["updated"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
try try
{ {
Directory.CreateDirectory(AssistentDataDir()); Memory.SetKvObject(AssistentMemory.KvUiState, clean);
string path = AssistentUiStatePath(); return new JObject { ["success"] = true, ["path"] = "Assistent/memory/assistent.sqlite" };
File.WriteAllText(path, clean.ToString(Newtonsoft.Json.Formatting.Indented), Encoding.UTF8);
return new JObject { ["success"] = true, ["path"] = path };
} }
catch (Exception ex) 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 ## When not to write
- Do not dump Exact defaults or the full inventory into vector memory. - 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 trivia already in `memory_hits`.
- Do not upsert Danbooru tags — the csv catalog already has them. - Do not upsert Danbooru tags — the csv catalog already has them.
- `memory_forget` without `scope` only removes the personal overlay. - `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. 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 ## Layout
@@ -24,12 +24,10 @@ Disk overlay (wins over bundled): `/mnt/swarm_data/Assistent/` — same folder l
``` ```
Assistent/ Assistent/
_base/ personas/<id>/ # overlay presets — same names as Config/, sparse _base/ personas/<id>/ # overlay presets — same names as Config/, sparse
settings.json # embed_model, base_url, per-persona skills settings.json # embed_model, base_url, per-persona skills (config overlay)
ui-state.json # pack / persona / auto_* / pane_width / models — seeds a fresh browser ollama-roles.json # chat vs memory model tags (gpu-rent writes this)
taste.json # learned taste profile (wins over localStorage) memory/assistent.sqlite # vector memory + tags FTS + chats + ui_state + taste
chats/<id>.json # chat history, newest 60 kept _migrated_json/ # one-shot archive of old chats/*.json, ui-state.json, taste.json
ollama-roles.json # chat vs memory model tags
memory/assistent.sqlite # vector store (shared + per-persona)
``` ```
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. 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 - 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 - ⚙ → **Память** lists every row (scope · source · date) with a per-row forget; bundled rows are read-only because reseed brings them back
## Chats on disk ## Chats and runtime KV
- 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
- 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 ## VRAM handover
- Before every Generate the chat model is unloaded (`keep_alive: 0`) so Krea 2 gets the whole GPU - 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) | | `AssistentGetCard` / `AssistentSaveCard` | `.assistent.json` cards (+ memory ingest) |
| `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash | | `AssistentGetCardMeta` | Local sidecar + optional Civitai by-hash |
| `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) | | `AssistentEnqueueWanted` / `AssistentListWanted` | Wanted YAML queue (write / read + count) |
| `AssistentGetTaste` / `AssistentSaveTaste` | Persistent taste profile | | `AssistentGetTaste` / `AssistentSaveTaste` | sqlite `kv.taste` |
| `AssistentSearchCivitai` | Civitai LoRA search | | `AssistentSearchCivitai` | Civitai LoRA search |
| `AssistentChat` / `AssistentChatWS` | Chat (+ hybrid memory + Civitai/tag hops) | | `AssistentChat` / `AssistentChatWS` | Chat (+ hybrid memory + Civitai/tag hops) |
| `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) | | `AssistentListMemory` / `AssistentUpsertMemory` / `AssistentForgetMemory` | Vector store (optional `scope` / `persona`) |
| `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key | | `AssistentSearchMemory` / `AssistentGetMemory` | Hybrid search / exact kind+key |
| `AssistentLookupTags` | Danbooru csv FTS (no embeddings) | | `AssistentLookupTags` | Danbooru csv FTS (no embeddings) |
| `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | `Assistent/chats/<id>.json` | | `AssistentListChats` / `AssistentGetChat` / `AssistentSaveChat` / `AssistentDeleteChat` | sqlite `chats` (optional `q` FTS) |
| `AssistentGetUiState` / `AssistentSaveUiState` | `Assistent/ui-state.json` | | `AssistentGetUiState` / `AssistentSaveUiState` | sqlite `kv.ui_state` |
| `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM | | `AssistentParkLlm` / `AssistentWarmLlm` | Unload / reload the chat model in VRAM |
## License ## License
-1
View File
@@ -1,6 +1,5 @@
using System; using System;
using System.IO; using System.IO;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
+1
View File
@@ -50,6 +50,7 @@
</div> </div>
<div class="sa-chats-panel" id="sa_chats_panel" hidden> <div class="sa-chats-panel" id="sa_chats_panel" hidden>
<div class="sa-chats-panel-head">Сохранённые чаты</div> <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-list" id="sa_chats_list"></div>
<div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div> <div class="sa-chats-panel-hint">Старт = всегда новый чат. Клик по чату восстанавливает сообщения и параметры Generate.</div>
</div> </div>