← All source files

V1 / 8f6c47b125e8

ProviderClient.cs

Lilith/Core/Providers/ProviderClient.cs

Changed in this version
CS
namespace Lilith.Core.Providers;

/// <summary>Conversation operations reject overlap rather than mixing sessions or snapshots.</summary>
public abstract class ProviderClient(Logger logger, ChatHistoryStore? historyStore = null, HttpClient? httpClient = null) : IDisposable
{
    private readonly bool _ownsHttp = httpClient is null;
    private readonly SemaphoreSlim _operation = new(1, 1);
    private bool _disposed;
    public HttpClient Http { get; } = httpClient ?? new() { Timeout = TimeSpan.FromMinutes(20) };
    public string Model { get; set; } = "gemma4";
    protected int NumCtx { get; set; } = 131072;
    public int ContextMax => NumCtx;
    public string SystemPrompt { get; protected set; } = string.Empty;
    /// <summary>Inspect only while idle. Use conversation methods to change an active session.</summary>
    public ChatHistory History { get; } = [];
    public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();
    public Logger Logger { get; } = logger;
    public string AgentName { get; set; } = "Lilith";
    public int ContextUsagePercent { get; private set; }
    public int CurrentContextTokens { get; private set; }
    public int ContextWarningThreshold { get; set; } = 60;
    public event Action<int>? OnContextHighUsage;

    public void ContextHighUsage(Func<int, string> message, string color = LogColors.Orange) =>
        OnContextHighUsage += pct => Logger.WriteLine("System", message(pct), color);

    protected void InitializeConversation()
    {
        History.Add("system", SystemPrompt);
        ContextHighUsage(pct => $"\u26a0 Context window is {pct}% full — history is getting long.");
    }

    private IDisposable EnterOperation()
    {
        var operation = _operation.EnterOrThrow("A conversation operation is already in progress.");
        if (_disposed) { operation.Dispose(); throw new ObjectDisposedException(GetType().Name); }
        return operation;
    }

    public void NewConversation()
    {
        using var operation = EnterOperation();
        SaveCore();
        HistoryStore.StartNewSession();
        ClearCore();
        Logger.WriteLine("System", "New conversation started.", LogColors.Cyan);
    }

    public void SaveConversation() => SaveCurrentConversation();
    public IReadOnlyList<ChatSessionSummary> ListConversations() => ListSavedConversations();
    public bool LoadConversation(int displayIndex) => LoadSavedConversation(displayIndex);
    public bool LoadConversation(string id) => LoadSavedConversation(id);
    public void ClearHistory() { using var operation = EnterOperation(); ClearCore(); }

    private void ClearCore()
    {
        History.Clear();
        ResetUsage();
        if (!string.IsNullOrWhiteSpace(SystemPrompt)) History.Add("system", SystemPrompt);
    }

    private void ResetUsage() { CurrentContextTokens = 0; ContextUsagePercent = 0; }
    public void StartNewSession() { using var operation = EnterOperation(); HistoryStore.StartNewSession(); }
    public void SaveCurrentConversation() { using var operation = EnterOperation(); SaveCore(); }

    private void SaveCore()
    {
        try
        {
            if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))
                HistoryStore.SaveSession(Model, History);
        }
        catch (Exception ex) { Logger.WriteWarning("History", $"Could not save chat history: {ex.Message}"); }
    }

    public IReadOnlyList<ChatSessionSummary> ListSavedConversations()
    {
        using var operation = EnterOperation();
        return HistoryStore.ListSessions();
    }

    public bool LoadSavedConversation(int displayIndex)
    {
        using var operation = EnterOperation();
        var sessions = HistoryStore.ListSessions();
        return displayIndex >= 1 && displayIndex <= sessions.Count && LoadCore(sessions[displayIndex - 1].Id);
    }

    public bool LoadSavedConversation(string id) { using var operation = EnterOperation(); return LoadCore(id); }

    private bool LoadCore(string id)
    {
        string previousId = HistoryStore.CurrentSessionId;
        try
        {
            var session = HistoryStore.LoadSession(id);
            if (session is null) return false;
            var messages = session.Messages.Select(m => m.ToChatMessage()).ToList();
            if (messages[0].Role != "system")
            {
                if (string.IsNullOrWhiteSpace(SystemPrompt)) throw new InvalidDataException("System prompt content is missing.");
                messages.Insert(0, new ChatMessage("system", SystemPrompt));
            }
            History.Clear();
            History.AddRange(messages);
            ResetUsage();
            return true;
        }
        catch (Exception ex)
        {
            HistoryStore.UseSession(previousId);
            Logger.WriteWarning("History", $"Could not load chat history: {ex.Message}");
            return false;
        }
    }

    protected abstract Task<ChatChunk?> GenerateReplyAsync(List<ChatMessage> messages);

    public async Task<string> StreamReply(string userInput, List<string>? images = null)
    {
        using var operation = EnterOperation();
        if (string.IsNullOrWhiteSpace(userInput)) throw new ArgumentException("User input cannot be blank.", nameof(userInput));
        var userMessage = new ChatMessage("user", userInput, Images: images is null ? null : [.. images]);
        var requestHistory = History.Select(m => m.Snapshot()).ToList();
        requestHistory.Add(userMessage);
        Logger.WriteLine("User", $"User: {userInput}", LogColors.Green);
        ChatChunk? chunk = await GenerateReplyAsync(requestHistory);
        if (chunk is not { Done: true, Message: { Role: "assistant", Content: not null } } ||
            string.IsNullOrWhiteSpace(chunk.Message.Content) || chunk.PromptTokens is < 0 || chunk.CompletionTokens is < 0)
            throw new InvalidDataException("The model provider did not return a complete nonempty assistant reply with valid token counts.");
        string reply = chunk.Message.Content;
        History.Add(userMessage with { Tokens = chunk.PromptTokens });
        History.Add("assistant", reply, chunk.CompletionTokens);
        SaveCore();
        Logger.WriteLine(AgentName, $"{AgentName}: {reply}", LogColors.Yellow);
        ResetUsage();
        if (chunk.PromptTokens is { } prompt && chunk.CompletionTokens is { } completion && NumCtx > 0)
        {
            CurrentContextTokens = (int)Math.Min(int.MaxValue, (long)prompt + completion);
            ContextUsagePercent = (int)Math.Min(int.MaxValue, (long)CurrentContextTokens * 100 / NumCtx);
            if (ContextUsagePercent >= ContextWarningThreshold && OnContextHighUsage is { } handlers)
                foreach (var handler in handlers.GetInvocationList().Cast<Action<int>>())
                    try { handler(ContextUsagePercent); }
                    catch (Exception ex) { Logger.WriteWarning("System", $"Context warning callback failed: {ex.Message}"); }
        }
        return reply;
    }

    public void Dispose()
    {
        using var operation = _operation.EnterOrThrow("Cannot dispose a provider during a conversation operation.");
        if (_disposed) return;
        _disposed = true;
        if (_ownsHttp) Http.Dispose();
        GC.SuppressFinalize(this);
    }

}