Chat/ChatHistoryStore/ChatHistoryStore.cscsharp

Documentation

ChatHistoryStore

Persist chat sessions to disk as XML.

ChatHistoryStore.cs

  • ChatHistoryStore — save, load, and list conversation files under a data directory.
using System.Text.RegularExpressions;using System.Xml.Linq;namespace Agent.Core.Ollama;public record ChatSessionSummary(    string Id,    string Title,    DateTimeOffset CreatedAt,    DateTimeOffset UpdatedAt,    int MessageCount,    string Model);public class SavedChatSession{    public string Id { get; set; } = string.Empty;    public string Title { get; set; } = "New chat";    public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.Now;    public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.Now;    public string Model { get; set; } = string.Empty;    public List<SavedChatMessage> Messages { get; set; } = [];}public class SavedChatMessage{    public string Role { get; set; } = string.Empty;    public string Content { get; set; } = string.Empty;    public int? Tokens { get; set; }    public List<string>? Images { get; set; }}public class ChatHistoryStore{    public string CurrentSessionId { get; private set; } = NewSessionId();    public string RootDirectory { get; }    public ChatHistoryStore() : this(null)    {    }    /// <param name="rootDirectoryOverride">Optional folder for tests or custom deployments.</param>    public ChatHistoryStore(string? rootDirectoryOverride)    {        if (!string.IsNullOrWhiteSpace(rootDirectoryOverride))        {            RootDirectory = rootDirectoryOverride;            return;        }        string? envRoot = Environment.GetEnvironmentVariable("AGENT_CORE_CHAT_HISTORY_ROOT");        if (!string.IsNullOrWhiteSpace(envRoot))        {            RootDirectory = envRoot;            return;        }        string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);        if (string.IsNullOrWhiteSpace(appData))        {            appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);        }        if (string.IsNullOrWhiteSpace(appData))        {            appData = AppContext.BaseDirectory;        }        RootDirectory = Path.Combine(appData, "AgentCore", DetectVersionLabel(), "ChatHistory");    }    public void StartNewSession() => CurrentSessionId = NewSessionId();    public void UseSession(string id)    {        if (!string.IsNullOrWhiteSpace(id))        {            CurrentSessionId = SanitizeId(id);        }    }    public string SaveSession(string model, IEnumerable<ChatMessage> history) =>        SaveSession(CurrentSessionId, model, history);    public string SaveSession(string? sessionId, string model, IEnumerable<ChatMessage> history)    {        Directory.CreateDirectory(RootDirectory);        string id = string.IsNullOrWhiteSpace(sessionId) ? NewSessionId() : SanitizeId(sessionId);        string path = SessionPath(id);        DateTimeOffset now = DateTimeOffset.Now;        DateTimeOffset createdAt = ReadCreatedAt(path) ?? now;        var messages = history            .Select(m => new SavedChatMessage            {                Role = m.Role,                Content = m.Content ?? string.Empty,                Tokens = m.Tokens,                Images = m.Images is { Count: > 0 } ? [.. m.Images] : null            })            .ToList();        var doc = new XDocument(            new XElement("chatSession",                new XAttribute("id", id),                new XAttribute("title", MakeTitle(messages)),                new XAttribute("model", model),                new XAttribute("createdAt", createdAt.ToString("O")),                new XAttribute("updatedAt", now.ToString("O")),                new XElement("messages",                    messages.Select(m =>                        new XElement("message",                            new XAttribute("role", m.Role),                            m.Tokens.HasValue ? new XAttribute("tokens", m.Tokens.Value) : null,                            new XElement("content", m.Content),                            m.Images is { Count: > 0 }                                ? new XElement("images", m.Images.Select(i => new XElement("image", i)))                                : null)))));        doc.Save(path);        CurrentSessionId = id;        return id;    }    public IReadOnlyList<ChatSessionSummary> ListSessions()    {        if (!Directory.Exists(RootDirectory))        {            return [];        }        return Directory.EnumerateFiles(RootDirectory, "*.xml")            .Select(ReadSummary)            .Where(s => s is not null)            .Select(s => s!)            .OrderByDescending(s => s.UpdatedAt)            .ToList();    }    public SavedChatSession? LoadSession(string id)    {        string path = SessionPath(SanitizeId(id));        if (!File.Exists(path))        {            return null;        }        XDocument doc = XDocument.Load(path);        XElement root = doc.Root ?? new XElement("chatSession");        var session = new SavedChatSession        {            Id = (string?)root.Attribute("id") ?? id,            Title = (string?)root.Attribute("title") ?? "New chat",            Model = (string?)root.Attribute("model") ?? string.Empty,            CreatedAt = ParseDate((string?)root.Attribute("createdAt")) ?? DateTimeOffset.Now,            UpdatedAt = ParseDate((string?)root.Attribute("updatedAt")) ?? DateTimeOffset.Now,            Messages = root.Element("messages")?.Elements("message").Select(ReadMessage).ToList() ?? []        };        CurrentSessionId = session.Id;        return session;    }    private string SessionPath(string id) => Path.Combine(RootDirectory, SanitizeId(id) + ".xml");    private static SavedChatMessage ReadMessage(XElement element)    {        int? tokens = int.TryParse((string?)element.Attribute("tokens"), out int value) ? value : null;        var images = element.Element("images")?.Elements("image")            .Select(i => i.Value)            .Where(i => !string.IsNullOrWhiteSpace(i))            .ToList();        return new SavedChatMessage        {            Role = (string?)element.Attribute("role") ?? string.Empty,            Content = element.Element("content")?.Value ?? string.Empty,            Tokens = tokens,            Images = images is { Count: > 0 } ? images : null        };    }    private static ChatSessionSummary? ReadSummary(string path)    {        try        {            XDocument doc = XDocument.Load(path);            XElement root = doc.Root ?? new XElement("chatSession");            string id = (string?)root.Attribute("id") ?? Path.GetFileNameWithoutExtension(path);            int count = root.Element("messages")?.Elements("message").Count() ?? 0;            return new ChatSessionSummary(                id,                (string?)root.Attribute("title") ?? "New chat",                ParseDate((string?)root.Attribute("createdAt")) ?? new DateTimeOffset(File.GetCreationTime(path)),                ParseDate((string?)root.Attribute("updatedAt")) ?? new DateTimeOffset(File.GetLastWriteTime(path)),                count,                (string?)root.Attribute("model") ?? string.Empty);        }        catch        {            return null;        }    }    private static DateTimeOffset? ReadCreatedAt(string path)    {        if (!File.Exists(path))        {            return null;        }        try        {            XDocument doc = XDocument.Load(path);            return ParseDate((string?)doc.Root?.Attribute("createdAt"));        }        catch        {            return null;        }    }    private static DateTimeOffset? ParseDate(string? value) =>        DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;    private static string MakeTitle(IReadOnlyList<SavedChatMessage> messages)    {        string? firstUser = messages            .Where(m => m.Role.Equals("user", StringComparison.OrdinalIgnoreCase))            .Select(m => m.Content.Trim())            .FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&                !m.StartsWith("Please greet", StringComparison.OrdinalIgnoreCase) &&                !m.Equals("hello", StringComparison.OrdinalIgnoreCase));        if (string.IsNullOrWhiteSpace(firstUser))        {            firstUser = messages.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m.Content))?.Content.Trim();        }        if (string.IsNullOrWhiteSpace(firstUser))        {            return "New chat";        }        firstUser = Regex.Replace(firstUser, @"\s+", " ");        return firstUser.Length <= 60 ? firstUser : firstUser[..57] + "...";    }    private static string DetectVersionLabel()    {        foreach (string source in new[] { AppContext.BaseDirectory, Environment.CurrentDirectory })        {            var match = Regex.Match(source, @"Version\s+\d+", RegexOptions.IgnoreCase);            if (match.Success)            {                return Regex.Replace(match.Value, @"\s+", " ");            }        }        return "Default";    }    private static string NewSessionId() => DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff");    private static string SanitizeId(string id) =>        string.Concat(id.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_'));}