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); // Start background spinner using var spinnerCts = new CancellationTokenSource(); var spinnerTask = Task.Run(async () => { var spinner = new[] { "|", "/", "-", "\\" }; int spinIdx = 0; while (!spinnerCts.Token.IsCancellationRequested) { var spinChar = spinner[spinIdx % spinner.Length]; spinIdx++; Logger.WriteProgress(AgentName, $"{AgentName}: [Thinking {spinChar}]"); await Task.Delay(150, spinnerCts.Token); } }); HttpResponseMessage httpResp; try { httpResp = await Http.SendAsync(httpReq); httpResp.EnsureSuccessStatusCode(); } finally { spinnerCts.Cancel(); try { await spinnerTask; } catch (OperationCanceledException) { } catch { } } string json = await httpResp.Content.ReadAsStringAsync(); ChatChunk? chunk = json.Deserialize<ChatChunk>(); string reply = chunk?.Message?.Content ?? string.Empty; Logger.WriteProgress("Lilith", ""); // clear thinking line Logger.Write("Lilith", "\rLilith: " + 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);}
Documentation
OllamaClient
Ollama chat client addon.
OllamaClient.cs
OllamaClient— chat streaming, context tracking, model install/wait, and history integration.