diff --git a/shell/agents/AIShell.Ollama.Agent/Command.cs b/shell/agents/AIShell.Ollama.Agent/Command.cs new file mode 100644 index 00000000..dbc66422 --- /dev/null +++ b/shell/agents/AIShell.Ollama.Agent/Command.cs @@ -0,0 +1,309 @@ +using System.CommandLine; +using System.CommandLine.Completions; +using System.Threading.Tasks; +using AIShell.Abstraction; + +namespace AIShell.Ollama.Agent; + +internal sealed class PresetCommand : CommandBase +{ + private readonly OllamaAgent _agnet; + + public PresetCommand(OllamaAgent agent) + : base("preset", "Command for preset management within the 'ollama' agent.") + { + _agnet = agent; + + var use = new Command("use", "Specify a preset to use."); + var usePreset = new Argument( + name: "Preset", + getDefaultValue: () => null, + description: "Name of a preset.").AddCompletions(PresetNameCompleter); + use.AddArgument(usePreset); + use.SetHandler(UsePresetAction, usePreset); + + var list = new Command("list", "List a specific preset, or all configured presets."); + var listPreset = new Argument( + name: "Preset", + getDefaultValue: () => null, + description: "Name of a preset.").AddCompletions(PresetNameCompleter); + list.AddArgument(listPreset); + list.SetHandler(ListPresetAction, listPreset); + + AddCommand(list); + AddCommand(use); + } + + private void ListPresetAction(string name) + { + IHost host = Shell.Host; + + // Reload the setting file if needed. + _agnet.ReloadSettings(); + + Settings settings = _agnet.Settings; + + if (settings is null) + { + host.WriteErrorLine("Error loading the configuration."); + return; + } + + try + { + if (string.IsNullOrEmpty(name)) + { + settings.ListAllPresets(host); + return; + } + + settings.ShowOnePreset(host, name); + } + catch (Exception ex) + { + string availablePresetNames = PresetNamesAsString(); + host.WriteErrorLine($"{ex.Message} Available preset(s): {availablePresetNames}."); + } + } + + private async Task UsePresetAction(string name) + { + // Reload the setting file if needed. + _agnet.ReloadSettings(); + + var setting = _agnet.Settings; + var host = Shell.Host; + + if (setting is null) + { + host.WriteErrorLine("Error loading the configuration."); + return; + } + + if (setting.Presets.Count is 0) + { + host.WriteErrorLine("There are no presets configured."); + return; + } + + try + { + ModelConfig chosenPreset = (string.IsNullOrEmpty(name) + ? await host.PromptForSelectionAsync( + title: "[orange1]Please select a [Blue]Preset[/] to use[/]:", + choices: setting.Presets, + converter: PresetName, + CancellationToken.None) + : setting.Presets.FirstOrDefault(c => c.Name == name)) ?? throw new InvalidOperationException($"The preset '{name}' doesn't exist."); + await setting.UsePreset(host, chosenPreset); + host.MarkupLine($"Using the preset [green]{chosenPreset.Name}[/]:"); + } + catch (Exception ex) + { + string availablePresetNames = PresetNamesAsString(); + host.WriteErrorLine($"{ex.Message} Available presets: {availablePresetNames}."); + } + } + + private static string PresetName(ModelConfig preset) => preset.Name.Any(Char.IsWhiteSpace) ? $"\"{preset.Name}\"" : preset.Name; + private IEnumerable PresetNameCompleter(CompletionContext context) => _agnet.Settings?.Presets?.Select(PresetName) ?? []; + private string PresetNamesAsString() => string.Join(", ", PresetNameCompleter(null)); +} + +internal sealed class SystemPromptCommand : CommandBase +{ + private readonly OllamaAgent _agnet; + + public SystemPromptCommand(OllamaAgent agent) + : base("system-prompt", "Command for system prompt management within the 'ollama' agent.") + { + _agnet = agent; + + var show = new Command("show", "Show the current system prompt."); + show.SetHandler(ShowSystemPromptAction); + + var set = new Command("set", "Sets the system prompt."); + var systemPromptModel = new Argument( + name: "System-Prompt", + getDefaultValue: () => null, + description: "The system prompt"); + set.AddArgument(systemPromptModel); + set.SetHandler(SetSystemPromptAction, systemPromptModel); + + AddCommand(show); + AddCommand(set); + } + + private void ShowSystemPromptAction() + { + IHost host = Shell.Host; + + // Reload the setting file if needed. + _agnet.ReloadSettings(); + + Settings settings = _agnet.Settings; + + if (settings is null) + { + host.WriteErrorLine("Error loading the configuration."); + return; + } + + try + { + settings.ShowSystemPrompt(host); + } + catch (Exception ex) + { + host.WriteErrorLine(ex.Message); + } + } + + private void SetSystemPromptAction(string prompt) + { + IHost host = Shell.Host; + + // Reload the setting file if needed. + _agnet.ReloadSettings(); + _agnet.ResetContext(); + + Settings settings = _agnet.Settings; + + if (settings is null) + { + host.WriteErrorLine("Error loading the configuration."); + return; + } + + try + { + settings.SetSystemPrompt(host, prompt); + } + catch (Exception ex) + { + host.WriteErrorLine(ex.Message); + } + } +} + +internal sealed class ModelCommand : CommandBase +{ + private readonly OllamaAgent _agnet; + + public ModelCommand(OllamaAgent agent) + : base("model", "Command for model management within the 'ollama' agent.") + { + _agnet = agent; + + var use = new Command("use", "Specify a model to use, or choose one from the available models."); + var useModel = new Argument( + name: "Model", + getDefaultValue: () => null, + description: "Name of a model.").AddCompletions(ModelNameCompleter); + use.AddArgument(useModel); + use.SetHandler(UseModelAction, useModel); + + var list = new Command("list", "List a specific model, or all available models."); + var listModel = new Argument( + name: "Model", + getDefaultValue: () => null, + description: "Name of a model.").AddCompletions(ModelNameCompleter); + list.AddArgument(listModel); + list.SetHandler(ListModelAction, listModel); + + AddCommand(list); + AddCommand(use); + } + + private async Task ListModelAction(string name) + { + IHost host = Shell.Host; + + // Reload the setting file if needed. + _agnet.ReloadSettings(); + + Settings settings = _agnet.Settings; + + if (settings is null) + { + host.WriteErrorLine("Error loading the configuration."); + return; + } + try + { + if (string.IsNullOrEmpty(name)) + { + await settings.ListAllModels(host); + return; + } + + await settings.ShowOneModel(host, name); + } + catch (Exception ex) + { + host.WriteErrorLine(ex.Message); + } + } + + private async Task UseModelAction(string name) + { + // Reload the setting file if needed. + _agnet.ReloadSettings(); + + var settings = _agnet.Settings; + var host = Shell.Host; + + if (settings is null) + { + host.WriteErrorLine("Error loading the configuration."); + return; + } + + try + { + bool success = await settings.PerformSelfcheck(host, checkEndpointOnly: true); + if (!success) + { + return; + } + + var allModels = await settings.GetAllModels(); + if (allModels.Count is 0) + { + host.WriteErrorLine($"No models found from '{settings.Endpoint}'."); + return; + } + + if (string.IsNullOrEmpty(name)) + { + name = await host.PromptForSelectionAsync( + title: "[orange1]Please select a [Blue]Model[/] to use[/]:", + choices: allModels, + CancellationToken.None); + } + + await settings.UseModel(host, name); + host.MarkupLine($"Using the model [green]{name}[/]"); + } + catch (Exception ex) + { + host.WriteErrorLine(ex.Message); + } + } + + private IEnumerable ModelNameCompleter(CompletionContext context) + { + try + { + // Model retrieval may throw. + var results = _agnet.Settings?.GetAllModels().Result; + if (results is not null) + { + return results; + } + } + catch (Exception) { } + + return []; + } +} diff --git a/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs b/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs index 480cf383..60da8520 100644 --- a/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs +++ b/shell/agents/AIShell.Ollama.Agent/OllamaAgent.cs @@ -1,14 +1,12 @@ -using System.Diagnostics; using System.Text; using System.Text.Json; -using System.Text.RegularExpressions; using AIShell.Abstraction; using OllamaSharp; using OllamaSharp.Models; namespace AIShell.Ollama.Agent; -public sealed partial class OllamaAgent : ILLMAgent +public sealed class OllamaAgent : ILLMAgent { private bool _reloadSettings; private bool _isDisposed; @@ -110,7 +108,7 @@ public void Initialize(AgentConfig config) /// /// Get commands that an agent can register to the shell when being loaded. /// - public IEnumerable GetCommands() => null; + public IEnumerable GetCommands() => [new PresetCommand(this), new ModelCommand(this), new SystemPromptCommand(this)]; /// /// Gets the path to the setting file of the agent. @@ -148,6 +146,11 @@ public Task RefreshChatAsync(IShell shell, bool force) return Task.CompletedTask; } + public void ResetContext() + { + _request.Context = null; + } + /// /// Main chat function that takes the users input and passes it to the LLM and renders it. /// @@ -165,17 +168,24 @@ public async Task ChatAsync(string input, IShell shell) // Reload the setting file if needed. ReloadSettings(); - if (IsLocalHost().IsMatch(_client.Uri.Host) && Process.GetProcessesByName("ollama").Length is 0) + bool success = await _settings.PerformSelfcheck(host); + if (!success) { - host.WriteErrorLine("Please be sure the Ollama is installed and server is running. Check all the prerequisites in the README of this agent are met."); return false; } + ModelConfig config = _settings.RunningConfig; + // Prepare request _request.Prompt = input; - _request.Model = _settings.Model; + _request.Model = config.ModelName; _request.Stream = _settings.Stream; + if (!string.IsNullOrWhiteSpace(config.SystemPrompt)) + { + _request.System = config.SystemPrompt; + } + try { if (_request.Stream) @@ -238,15 +248,15 @@ public async Task ChatAsync(string input, IShell shell) catch (HttpRequestException e) { host.WriteErrorLine($"{e.Message}"); - host.WriteErrorLine($"Ollama model: \"{_settings.Model}\""); - host.WriteErrorLine($"Ollama endpoint: \"{_settings.Endpoint}\""); - host.WriteErrorLine($"Ollama settings: \"{SettingFile}\""); + host.WriteErrorLine($"Ollama active model: \"{config.ModelName}\""); + host.WriteErrorLine($"Ollama endpoint: \"{_settings.Endpoint}\""); + host.WriteErrorLine($"Ollama settings: \"{SettingFile}\""); } return true; } - private void ReloadSettings() + internal void ReloadSettings() { if (_reloadSettings) { @@ -308,22 +318,24 @@ private void NewExampleSettingFile() // 1. Install Ollama: `winget install Ollama.Ollama` // 2. Start Ollama API server: `ollama serve` // 3. Install Ollama model: `ollama pull phi3` - - // Declare Ollama model - "Model": "phi3", + + // Declare predefined model configurations + "Presets": [ + { + "Name": "PowerShell Expert", + "Description": "A ollama agent with expertise in PowerShell scripting and command line utilities.", + "ModelName": "phi3", + "SystemPrompt": "1. You are a helpful and friendly assistant with expertise in PowerShell scripting and command line.\n2. Assume user is using the operating system `Windows 11` unless otherwise specified.\n3. Use the `code block` syntax in markdown to encapsulate any part in responses that is code, YAML, JSON or XML, but not table.\n4. When encapsulating command line code, use '```powershell' if it's PowerShell command; use '```sh' if it's non-PowerShell CLI command.\n5. When generating CLI commands, never ever break a command into multiple lines. Instead, always list all parameters and arguments of the command on the same line.\n6. Please keep the response concise but to the point. Do not overexplain." + } + ], // Declare Ollama endpoint "Endpoint": "http://localhost:11434", // Enable Ollama streaming - "Stream": false + "Stream": false, + // Specify the default preset to use + "DefaultPreset": "PowerShell Expert" } """; File.WriteAllText(SettingFile, SampleContent, Encoding.UTF8); } - - /// - /// Defines a generated regular expression to match localhost addresses - /// "localhost", "127.0.0.1" and "[::1]" with case-insensitivity. - /// - [GeneratedRegex("^(localhost|127\\.0\\.0\\.1|\\[::1\\])$", RegexOptions.IgnoreCase)] - internal partial Regex IsLocalHost(); } diff --git a/shell/agents/AIShell.Ollama.Agent/README.md b/shell/agents/AIShell.Ollama.Agent/README.md index bd8e91e1..6e06f120 100644 --- a/shell/agents/AIShell.Ollama.Agent/README.md +++ b/shell/agents/AIShell.Ollama.Agent/README.md @@ -20,11 +20,21 @@ To configure the agent, run `/agent config ollama` to open up the setting file i // 2. Start Ollama API server: `ollama serve` // 3. Install Ollama model: `ollama pull phi3` - // Declare Ollama model - "Model": "phi3", + // Declare predefined model configurations + "Presets": [ + { + "Name": "PowerShell Expert", + "Description": "A ollama agent with expertise in PowerShell scripting and command line utilities.", + "ModelName": "phi3", + "SystemPrompt": "You are a helpful and friendly assistant with expertise in PowerShell scripting and command line." + } + ], + // Declare Ollama endpoint "Endpoint": "http://localhost:11434", // Enable Ollama streaming - "Stream": false + "Stream": false, + // Specify the default preset to use + "DefaultPreset": "PowerShell Expert" } ``` diff --git a/shell/agents/AIShell.Ollama.Agent/Settings.cs b/shell/agents/AIShell.Ollama.Agent/Settings.cs index 11ebd8de..8b1db2bd 100644 --- a/shell/agents/AIShell.Ollama.Agent/Settings.cs +++ b/shell/agents/AIShell.Ollama.Agent/Settings.cs @@ -1,40 +1,278 @@ -using System.Text.Json; +using System.Diagnostics; +using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using AIShell.Abstraction; +using OllamaSharp; namespace AIShell.Ollama.Agent; -internal class Settings +internal partial class Settings { - public string Model { get; } + private bool _initialized = false; + private bool _runningConfigChecked = false; + private bool? _isRunningLocalHost = null; + private List _availableModels = []; + public List Presets { get; } public string Endpoint { get; } public bool Stream { get; } + public ModelConfig RunningConfig { get; private set; } public Settings(ConfigData configData) { - // Validate Model and Endpoint for null or empty values - if (string.IsNullOrWhiteSpace(configData.Model)) - { - throw new ArgumentException("\"Model\" key is missing."); - } - if (string.IsNullOrWhiteSpace(configData.Endpoint)) { - throw new ArgumentException("\"Endpoint\" key is missing."); + throw new InvalidOperationException("'Endpoint' key is missing in configuration."); } - Model = configData.Model; + Presets = configData.Presets ?? []; Endpoint = configData.Endpoint; Stream = configData.Stream; + + if (string.IsNullOrEmpty(configData.DefaultPreset)) + { + RunningConfig = Presets.Count > 0 + ? Presets[0] with { } /* No default preset - use the first one defined in Presets */ + : new ModelConfig(name: nameof(RunningConfig), modelName: ""); /* No presets are defined - use empty */ + } + else + { + // Ensure the default configuration is available in the list of configurations. + var first = Presets.FirstOrDefault(c => c.Name == configData.DefaultPreset) + ?? throw new InvalidOperationException($"The selected default preset '{configData.DefaultPreset}' doesn't exist."); + // Use the default config + RunningConfig = first with { }; + } + } + + /// + /// Retrieve available models from the Ollama endpoint. + /// + /// Used for writing error to host when it's a local endpoint but the Ollama server is not started. When the value is null, the endpoint check will be skipped. + /// Used for cancel the operation. + /// + private async Task EnsureModelsInitialized(IHost host, CancellationToken cancellationToken = default) + { + if (_initialized) + { + return true; + } + + // The endpoint check is supposed to be interactive and can be skipped in some cases, such as when + // the `PerformSelfcheck` method was already called right before entering this method. + // So, we will simply skip the endpoint check when the passed-in host is null. If there's anything + // wrong with the endpoint, the subsequent calls to retrieve models will fail and throw anyway. + if (host is not null) + { + bool success = await PerformSelfcheck(host, checkEndpointOnly: true); + if (!success) + { + return false; + } + } + + using OllamaApiClient client = new(Endpoint); + var models = await client.ListLocalModelsAsync(cancellationToken).ConfigureAwait(false); + _availableModels = [.. models.Select(m => m.Name)]; + _initialized = true; + return true; + } + + internal async Task> GetAllModels(IHost host = null, CancellationToken cancellationToken = default) + { + if (await EnsureModelsInitialized(host, cancellationToken).ConfigureAwait(false)) + { + return _availableModels; + } + + return []; + } + + internal void EnsureModelNameIsValid(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + if (!_availableModels.Contains(name.AddLatestTagIfNecessery())) + { + throw new InvalidOperationException($"A model with the name '{name}' doesn't exist. The available models are: [{string.Join(", ", _availableModels)}]."); + } + } + + private static List> GetSystemPromptRenderElements() => [new CustomElement(label: "System prompt", s => s)]; + + internal void ShowSystemPrompt(IHost host) => host.RenderList(RunningConfig.SystemPrompt, GetSystemPromptRenderElements()); + + internal void SetSystemPrompt(IHost host, string prompt) + { + RunningConfig = RunningConfig with { SystemPrompt = prompt ?? string.Empty }; + host.RenderList(RunningConfig.SystemPrompt, GetSystemPromptRenderElements()); + } + + private static List> GetRenderModelElements(Func isActive) => [ + new CustomElement(label: "Name", m => m), + new CustomElement(label: "Active", m => isActive(m) ? "true" : string.Empty) + ]; + + internal async Task UseModel(IHost host, string name, CancellationToken cancellationToken = default) + { + if (await EnsureModelsInitialized(host, cancellationToken).ConfigureAwait(false)) + { + EnsureModelNameIsValid(name); + RunningConfig = RunningConfig with { ModelName = name }; + _runningConfigChecked = true; + } + } + + internal async Task ListAllModels(IHost host, CancellationToken cancellationToken = default) + { + if (await EnsureModelsInitialized(host, cancellationToken).ConfigureAwait(false)) + { + host.RenderTable(_availableModels, GetRenderModelElements(m => m == RunningConfig.ModelName.AddLatestTagIfNecessery())); + } } + + internal async Task ShowOneModel(IHost host, string name, CancellationToken cancellationToken = default) + { + if (await EnsureModelsInitialized(host, cancellationToken).ConfigureAwait(false)) + { + EnsureModelNameIsValid(name); + host.RenderList(name, GetRenderModelElements(m => m == RunningConfig.ModelName.AddLatestTagIfNecessery())); + } + } + + internal async Task UsePreset(IHost host, ModelConfig preset, CancellationToken cancellationToken = default) + { + if (await EnsureModelsInitialized(host, cancellationToken).ConfigureAwait(false)) + { + EnsureModelNameIsValid(preset.ModelName); + RunningConfig = preset with { }; + _runningConfigChecked = true; + } + } + + internal void ListAllPresets(IHost host) + { + host.RenderTable( + Presets, + [ + new PropertyElement(nameof(ModelConfig.Name)), + new CustomElement(label: "Active", m => m == RunningConfig ? "true" : string.Empty) + ]); + } + + internal void ShowOnePreset(IHost host, string name) + { + var preset = Presets.FirstOrDefault(c => c.Name == name); + if (preset is null) + { + host.WriteErrorLine($"The preset '{name}' doesn't exist."); + return; + } + + host.RenderList( + preset, + [ + new PropertyElement(nameof(ModelConfig.Name)), + new PropertyElement(nameof(ModelConfig.Description)), + new PropertyElement(nameof(ModelConfig.ModelName)), + new PropertyElement(nameof(ModelConfig.SystemPrompt)), + new CustomElement(label: "Active", m => m == RunningConfig ? "true" : string.Empty), + ]); + } + + internal async Task PerformSelfcheck(IHost host, bool checkEndpointOnly = false) + { + _isRunningLocalHost ??= IsLocalHost().IsMatch(new Uri(Endpoint).Host); + + if (_isRunningLocalHost is true && Process.GetProcessesByName("ollama").Length is 0) + { + host.WriteErrorLine("Please be sure the Ollama is installed and server is running. Check all the prerequisites in the README of this agent are met."); + return false; + } + + if (!checkEndpointOnly && !_runningConfigChecked) + { + // Skip the endpoint check in 'EnsureModelsInitialized' as we already did it. + await EnsureModelsInitialized(host: null).ConfigureAwait(false); + if (string.IsNullOrEmpty(RunningConfig.ModelName)) + { + // There is no model set, so use the first one available. + if (_availableModels.Count is 0) + { + host.WriteErrorLine($"No models are available to use from '{Endpoint}'."); + return false; + } + + RunningConfig = RunningConfig with { ModelName = _availableModels.First() }; + host.MarkupLine($"No Ollama model is configured. Using the first available model [green]'{RunningConfig.ModelName}'[/]."); + } + else + { + try + { + EnsureModelNameIsValid(RunningConfig.ModelName); + } + catch (InvalidOperationException e) + { + host.WriteErrorLine(e.Message); + return false; + } + } + + _runningConfigChecked = true; + } + + return true; + } + + /// + /// Defines a generated regular expression to match localhost addresses + /// "localhost", "127.0.0.1" and "[::1]" with case-insensitivity. + /// + [GeneratedRegex("^(localhost|127\\.0\\.0\\.1|\\[::1\\])$", RegexOptions.IgnoreCase)] + internal partial Regex IsLocalHost(); } -internal class ConfigData +/// +/// Represents a configuration for an Ollama model. +/// +internal record ModelConfig { - public string Model { get; set; } - public string Endpoint { get; set; } - public bool Stream { get; set; } + [JsonRequired] + public string Name { get; init; } + + [JsonRequired] + public string ModelName { get; init; } + + public string SystemPrompt { get; init; } = string.Empty; + + public string Description { get; init; } = string.Empty; + + /// + /// Initializes a new instance of the class with the specified parameters. + /// + /// The name of the model configuration. + /// The name of the model to be used. + /// An optional system prompt to guide the model's behavior. Defaults to an empty string. + /// An optional description of the model configuration. Defaults to an empty string. + public ModelConfig(string name, string modelName, string systemPrompt = "", string description = "") + { + Name = name; + ModelName = modelName; + SystemPrompt = systemPrompt; + Description = description; + } } +/// +/// Represents the configuration data for the AI Shell Ollama Agent. +/// +/// Optional. A list of predefined model configurations. +/// Optional. The endpoint URL for the agent. Defaults to "http://localhost:11434" +/// Optional. Indicates whether streaming is enabled. Defaults to false. +/// Optional. Specifies the default preset name. If not provided, the first available preset will be used. +internal record ConfigData(List Presets, string Endpoint = "http://localhost:11434", bool Stream = false, string DefaultPreset = ""); + /// /// Use source generation to serialize and deserialize the setting file. /// Both metadata-based and serialization-optimization modes are used to gain the best performance. @@ -47,3 +285,9 @@ internal class ConfigData UseStringEnumConverter = true)] [JsonSerializable(typeof(ConfigData))] internal partial class SourceGenerationContext : JsonSerializerContext { } + +static class TagExtensions +{ + public static string AddLatestTagIfNecessery(this string model) => + model.Contains(':') ? model : string.Concat(model, ":latest"); +}