V11 / ca6f28eff240
ConsoleSetup.cs
Interfaces/ConsoleApp/LilithConsole/Setup/ConsoleSetup.cs
Unchanged from the preceding versionCS
using System.Text.Json;
using System.Text.RegularExpressions;
using Lilith.Core.Logging;
using Lilith.Core.Ollama;
using Lilith.Core.Extensions;
using Lilith.Agent;
namespace LilithConsole.Setup;
/// <summary>Checks actual installation/model state on every launch, independent of saved preferences.</summary>
public sealed class ConsoleSetup(TextReader input, TextWriter output, ISetupServices services, Logger logger, string settingsPath)
{
public static readonly IReadOnlyList<string> Models = Array.AsReadOnly<string>(["gemma4", "llama3.2", "qwen3:4b", "phi4-mini"]);
public async Task<LilithCredentials?> RunAsync()
{
using var totalTimer = new ConsoleSetupTimer(logger);
bool installed = await logger.TimeStageAsync("Installation check", () => Task.FromResult(services.IsInstalled()));
if (!installed)
{
if (!await logger.TimeStageAsync("Installation choice", () => Task.FromResult(Confirm("Would you like to download and install Ollama? [y/N, exit]: ")))) return null;
await logger.TimeStageAsync("Ollama installation", services.InstallAsync);
if (!services.IsInstalled()) throw new InvalidOperationException("Ollama executable was not found after installation.");
}
else output.WriteLine("Ollama already installed; skipping installation.");
await logger.TimeStageAsync("Server startup/check", services.EnsureServerAsync);
ConsoleSettings settings = await logger.TimeStageAsync("Preferences load", () => Task.FromResult(Load()));
if (settings.AgentName is null)
{
output.Write("Agent name (Enter for Lilith, /exit to leave): ");
string? name = await logger.TimeStageAsync("Agent name selection", () => Task.FromResult(input.ReadLine()));
if (name is null || name.Trim().Equals("/exit", StringComparison.OrdinalIgnoreCase)) return null;
name = name.Trim();
if (name.Length > 80 || name.Any(char.IsControl)) throw new ArgumentException("Agent name must be at most 80 characters without control characters.");
settings = settings with { AgentName = name.Length == 0 ? "Lilith" : name };
Save(settings); // Persist identity even if the application closes before a model is ready.
}
output.WriteLine($"Agent name: {settings.AgentName}");
string? overrideModel = Environment.GetEnvironmentVariable("LILITH_MODEL");
if (!string.IsNullOrWhiteSpace(overrideModel)) settings = settings with { Model = overrideModel.Trim() };
bool available = settings.Model is not null &&
await logger.TimeStageAsync("Saved model check", () => services.HasModelAsync(settings.Model));
if (!available)
{
if (settings.Model is not null) output.WriteLine($"Model '{settings.Model}' is not installed. Choose a model to continue.");
string? selected = await logger.TimeStageAsync("Model selection", () => Task.FromResult(SelectModel()));
if (selected is null) return null;
settings = settings with { Model = selected };
Save(settings); // Remember selection even if a download is interrupted.
available = await logger.TimeStageAsync("Selected model check", () => services.HasModelAsync(selected));
if (!available)
{
if (!await logger.TimeStageAsync("Download choice", () => Task.FromResult(Confirm($"Download model '{selected}'? [y/N, exit]: ")))) return null;
await logger.TimeStageAsync("Model download", () => services.DownloadAsync(selected));
if (!await logger.TimeStageAsync("Downloaded model validation", () => services.HasModelAsync(selected)))
throw new InvalidOperationException("Model is still unavailable after download. Relaunch setup to retry.");
}
else output.WriteLine($"Model '{selected}' already installed; skipping download.");
}
else output.WriteLine($"Model '{settings.Model}' already installed; skipping selection and download.");
Save(settings);
totalTimer.Completed = true;
return new LilithCredentials(settings.Model!,
$"You are {settings.AgentName}, a helpful AI assistant. Greet the user warmly when the conversation begins.", settings.AgentName!);
}
private string? SelectModel()
{
while (true)
{
output.WriteLine("Choose a model:");
for (int i = 0; i < Models.Count; i++) output.WriteLine($"{i + 1}. {Models[i]}");
output.WriteLine("5. Custom model\n0. Exit");
output.Write("Selection: ");
string? selection = input.ReadLine()?.Trim();
if (selection is null || selection.Equals("exit", StringComparison.OrdinalIgnoreCase) || selection == "0") return null;
if (int.TryParse(selection, out int index) && index is >= 1 and <= 4) return Models[index - 1];
if (selection == "5")
{
output.Write("Custom model name/tag (or exit): ");
string? custom = input.ReadLine()?.Trim();
if (custom is null || custom.Equals("exit", StringComparison.OrdinalIgnoreCase)) return null;
if (custom.Length <= 200 && RegexPatterns.OllamaModelIdentifier().IsMatch(custom)) return custom;
output.WriteLine("Enter a valid model name, for example llama3.2:1b.");
}
else output.WriteLine("Choose 1–5, or 0 to exit.");
}
}
private bool Confirm(string question)
{
while (true)
{
output.Write(question);
string? answer = input.ReadLine()?.Trim().ToLowerInvariant();
if (answer is null or "" or "n" or "no" or "exit" or "0") return false;
if (answer is "y" or "yes") return true;
output.WriteLine("Please enter y, n, or exit.");
}
}
private ConsoleSettings Load()
{
if (!File.Exists(settingsPath)) return new();
try
{
return JsonSettings.Load<ConsoleSettings>(settingsPath, settings => settings.IsValid());
}
catch (JsonException ex) { output.WriteLine($"Saved settings could not be read ({ex.Message}); setup will ask again."); return new(); }
}
private void Save(ConsoleSettings settings)
{
JsonSettings.Save(settingsPath, settings);
}
}