Ollama/OllamaClient/OllamaClient.cscsharp

Documentation

OllamaClient

Ollama chat client addon.

OllamaClient.cs

  • OllamaClient — chat streaming, context tracking, model install/wait, and history integration.
using System.Diagnostics;namespace Agent.Core.Ollama;public class OllamaClient(Logger logger, ChatHistoryStore? historyStore = null){    private const string StatusBlue = "#AAAAFF";    private const string StatusGray = "#AAAAAA";    public string BaseUrl { get; } = "http://localhost:11434";    public string ChatApiUrl => $"{BaseUrl}/api/chat";    public string ShowApiUrl => $"{BaseUrl}/api/show";    public HttpClient Http { get; } = new()    {        Timeout = TimeSpan.FromMinutes(20)    };    public string Model { get; set; } = "gemma4";    private int NumCtx { get; set; } = 131072;    public int ContextMax => NumCtx;    public string SystemPrompt { get; private set; } = string.Empty;    public ChatHistory History { get; } = [];    public ChatHistoryStore HistoryStore { get; } = historyStore ?? new ChatHistoryStore();    public Logger Logger { get; } = logger;    /// <summary>Console label for assistant replies (e.g. Eve, Lilith, Adam).</summary>    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 ClearHistory()    {        History.Clear();        CurrentContextTokens = 0;        if (!string.IsNullOrEmpty(SystemPrompt))        {            History.Add("system", SystemPrompt);        }    }    public void StartNewSession() => HistoryStore.StartNewSession();    public void SaveCurrentConversation()    {        try        {            if (History.Any(m => !m.Role.Equals("system", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(m.Content)))            {                HistoryStore.SaveSession(Model, History);            }        }        catch (Exception ex)        {            Logger.WriteLine("History", $"Could not save chat history: {ex.Message}", LogColors.Orange);        }    }    public IReadOnlyList<ChatSessionSummary> ListSavedConversations() => HistoryStore.ListSessions();    public bool LoadSavedConversation(int displayIndex)    {        var sessions = HistoryStore.ListSessions();        if (displayIndex < 1 || displayIndex > sessions.Count)        {            return false;        }        return LoadSavedConversation(sessions[displayIndex - 1].Id);    }    public bool LoadSavedConversation(string id)    {        try        {            var session = HistoryStore.LoadSession(id);            if (session is null)            {                return false;            }            History.Clear();            foreach (var message in session.Messages)            {                History.Add(new ChatMessage(message.Role, message.Content, message.Images, message.Tokens));            }            if (History.Count == 0 || !History[0].Role.Equals("system", StringComparison.OrdinalIgnoreCase))            {                History.Insert(0, new ChatMessage("system", SystemPrompt));            }            CurrentContextTokens = 0;            ContextUsagePercent = 0;            return true;        }        catch (Exception ex)        {            Logger.WriteLine("History", $"Could not load chat history: {ex.Message}", LogColors.Orange);            return false;        }    }    public async Task<string> StreamReply(string userInput, List<string>? images = null)    {        History.Add(new ChatMessage("user", userInput, Images: images));        var request = new ChatRequest(Model, History, Stream: false, Options: new ChatOptions(NumCtx));        using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ChatApiUrl, request);        using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);        httpResp.EnsureSuccessStatusCode();        string json = await httpResp.Content.ReadAsStringAsync();        ChatChunk? chunk = json.Deserialize<ChatChunk>();        string reply = chunk?.Message?.Content ?? string.Empty;        Logger.Write(AgentName, $"\r{AgentName}: " + reply, LogColors.Yellow);        History[^1] = History[^1] with { Tokens = chunk?.PromptTokens };        History.Add("assistant", reply, chunk?.CompletionTokens);        if (chunk?.PromptTokens is { } pt && NumCtx > 0)        {            CurrentContextTokens = pt;            ContextUsagePercent = (int)((long)pt * 100 / NumCtx);            if (ContextUsagePercent >= ContextWarningThreshold)            {                OnContextHighUsage?.Invoke(ContextUsagePercent);            }        }        SaveCurrentConversation();        return reply;    }    public static async Task<OllamaClient> CreateClient(        string model,        string systemPrompt,        Logger logger,        bool startOllamaIfNotRunning,        string agentName = "Lilith")    {        var client = new OllamaClient(logger)        {            Model = model,            SystemPrompt = systemPrompt,            AgentName = agentName        };        client.History.Add("system", systemPrompt);        client.PrintStatus($"Initializing client for model '{model}'...", StatusBlue);        if (!IsOllamaInstalled())        {            client.PrintStatus("Ollama not found on PATH — installing via official PowerShell installer...", LogColors.Yellow);            if (!await InstallOllamaAsync(client))            {                client.PrintStatus("Ollama installation failed. Please install manually from https://ollama.com.", LogColors.Red);                Environment.Exit(1);            }            client.PrintStatus("Ollama installed.", LogColors.Green);        }        client.PrintStatus("Checking Ollama connection...", StatusBlue);        if (await client.IsRunningAsync())        {            client.PrintStatus("Ollama is running.", LogColors.Green);            client.PrintStatus($"Running model '{model}' to ensure it is ready...", StatusBlue);            await PullModelAsync(client);            client.PrintStatus("Model ready.", LogColors.Green);            client.PrintStatus("Querying model specs...", StatusBlue);            client.NumCtx = await client.GetModelContextSizeAsync();            client.PrintStatus($"Context window: {client.NumCtx:N0} tokens.", LogColors.Green);            return client;        }        if (startOllamaIfNotRunning)        {            client.PrintStatus($"Ollama not detected — starting with model '{model}'...", LogColors.Yellow);            client.LaunchOllamaRun(model);        }        else        {            client.PrintStatus("Ollama not detected — waiting for it to start. Press Q to quit.", LogColors.Yellow);        }        while (!await client.IsRunningAsync())        {            if (Console.KeyAvailable)            {                ConsoleKey key = Console.ReadKey(intercept: true).Key;                if (key == ConsoleKey.Q)                {                    client.PrintStatus("Exiting — Ollama was not found.", LogColors.Red);                    Environment.Exit(0);                }            }            await Task.Delay(1000);        }        client.PrintStatus("Ollama is ready.", LogColors.Green);        client.PrintStatus($"Running model '{model}' to ensure it is ready...", StatusBlue);        await PullModelAsync(client);        client.PrintStatus("Model ready.", LogColors.Green);        client.PrintStatus("Querying model specs...", StatusBlue);        client.NumCtx = await client.GetModelContextSizeAsync();        client.PrintStatus($"Context window: {client.NumCtx:N0} tokens.", LogColors.Green);        return client;    }    private async Task<int> GetModelContextSizeAsync()    {        try        {            var request = new ShowModelRequest(Model);            using HttpRequestMessage httpReq = HttpExtensions.JsonRequest(ShowApiUrl, request);            using HttpResponseMessage httpResp = await Http.SendAsync(httpReq);            if (!httpResp.IsSuccessStatusCode)            {                PrintStatus("Could not retrieve model specs — using default context (131,072 tokens).", LogColors.Orange);                return 131072;            }            string json = await httpResp.Content.ReadAsStringAsync();            ShowModelResponse? response = json.Deserialize<ShowModelResponse>();            if (response?.ModelInfo is { } info)            {                foreach (var kv in info)                {                    if (kv.Key.EndsWith(".context_length") && kv.Value.TryGetInt32(out int ctx))                    {                        return ctx;                    }                }            }        }        catch (Exception ex)        {            PrintStatus($"Model spec query failed ({ex.Message}) — using default context (131,072 tokens).", LogColors.Orange);        }        return 131072;    }    private async Task<bool> IsRunningAsync() =>        await Http.IsReachableAsync($"{BaseUrl}/");    private static bool IsOllamaInstalled()    {        try        {            var psi = new ProcessStartInfo("ollama", "--version")            {                CreateNoWindow = true,                UseShellExecute = false,                RedirectStandardOutput = true,                RedirectStandardError = true,            };            using var proc = Process.Start(psi);            if (proc == null)            {                return false;            }            if (!proc.WaitForExit(5000))            {                try                {                    proc.Kill();                }                catch                {                    // ignore                }                return false;            }            return proc.ExitCode == 0;        }        catch        {            return false;        }    }    private static async Task<bool> PullModelAsync(OllamaClient client)    {        try        {            client.PrintStatus($"Pulling model '{client.Model}'…", StatusBlue);            var psi = new ProcessStartInfo            {                FileName = "ollama",                CreateNoWindow = true,                UseShellExecute = false,                RedirectStandardOutput = true,                RedirectStandardError = true,            };            psi.ArgumentList.Add("pull");            psi.ArgumentList.Add(client.Model);            using var proc = Process.Start(psi);            if (proc is null)            {                client.PrintStatus("Could not start ollama pull process.", LogColors.Red);                return false;            }            var tracker = new OllamaCliProgressTracker(client.Model);            Task stdoutTask = OllamaCliStreamReader.PumpAsync(proc.StandardOutput, tracker);            Task stderrTask = OllamaCliStreamReader.PumpAsync(proc.StandardError, tracker);            DateTime started = DateTime.UtcNow;            DateTime deadline = started.AddMinutes(30);            while (!proc.HasExited && DateTime.UtcNow < deadline)            {                TimeSpan elapsed = DateTime.UtcNow - started;                TimeSpan remaining = deadline - DateTime.UtcNow;                client.PrintProgress(tracker.GetDisplayMessage(elapsed, remaining), StatusGray);                await Task.Delay(250);            }            await proc.WaitForExitAsync();            await Task.WhenAll(stdoutTask, stderrTask);            if (proc.ExitCode != 0 && !await IsModelAvailableAsync(client.Model))            {                client.PrintStatus($"ollama pull failed (exit {proc.ExitCode}).", LogColors.Red);                return false;            }            if (await IsModelAvailableAsync(client.Model))            {                client.PrintStatus(                    $"Model '{client.Model}' ready ({(DateTime.UtcNow - started):mm\\:ss}).",                    LogColors.Green);                return true;            }            client.PrintStatus($"Timed out waiting for model '{client.Model}' to become available.", LogColors.Orange);            if (!proc.HasExited)            {                proc.Kill(entireProcessTree: true);            }            return false;        }        catch (Exception ex)        {            client.PrintStatus($"Model pull error: {ex.Message}", LogColors.Red);            return false;        }    }    private static async Task<bool> IsModelAvailableAsync(string model)    {        try        {            var psi = new ProcessStartInfo            {                FileName = "ollama",                CreateNoWindow = true,                UseShellExecute = false,                RedirectStandardOutput = true,                RedirectStandardError = true,            };            psi.ArgumentList.Add("show");            psi.ArgumentList.Add(model);            using var proc = Process.Start(psi);            if (proc == null)            {                return false;            }            Task exited = proc.WaitForExitAsync();            Task timeout = Task.Delay(TimeSpan.FromSeconds(10));            if (await Task.WhenAny(exited, timeout) != exited)            {                try                {                    proc.Kill(entireProcessTree: true);                }                catch                {                    // ignore                }                return false;            }            return proc.ExitCode == 0;        }        catch        {            return false;        }    }    private static async Task<bool> InstallOllamaAsync(OllamaClient client)    {        try        {            var psi = new ProcessStartInfo            {                FileName = "powershell",                Arguments = "-NoProfile -ExecutionPolicy Bypass -Command " +                            "\"irm https://ollama.com/install.ps1 | iex\"",                CreateNoWindow = true,                UseShellExecute = false,                RedirectStandardOutput = true,                RedirectStandardError = true,            };            using var proc = Process.Start(psi);            if (proc == null)            {                return false;            }            proc.OutputDataReceived += (_, e) =>            {                if (!string.IsNullOrEmpty(e.Data))                {                    client.PrintStatus(e.Data, StatusGray);                }            };            proc.ErrorDataReceived += (_, e) =>            {                if (!string.IsNullOrEmpty(e.Data))                {                    client.PrintStatus(e.Data, LogColors.Orange);                }            };            proc.BeginOutputReadLine();            proc.BeginErrorReadLine();            await proc.WaitForExitAsync();            return proc.ExitCode == 0 && IsOllamaInstalled();        }        catch (Exception ex)        {            client.PrintStatus($"Installer error: {ex.Message}", LogColors.Red);            return false;        }    }    private void LaunchOllamaRun(string model) =>        TryCatchExtensions.TryCatchCustomMessage(OllamaRunLog(model), () => OllamaRun(model));    private CatchLog OllamaRunLog(string model) =>        new("Ollama", $"Could not start Ollama with model '{model}'.", Logger);    private static Process? OllamaRun(string model) =>        Process.Start(new ProcessStartInfo("ollama", $"run {model}")        {            CreateNoWindow = true,            UseShellExecute = false        });    private void PrintStatus(string message, string color) =>        Logger.WriteLine("Ollama", message, color);    private void PrintProgress(string message, string color) =>        Logger.WriteProgress("Ollama", message, color);}