diff --git a/Smith.sln b/Smith.sln index 342cfc6..fb32f7d 100644 --- a/Smith.sln +++ b/Smith.sln @@ -1,10 +1,18 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 -VisualStudioVersion = 17.14.36221.1 d17.14 +VisualStudioVersion = 17.14.36221.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Smith", "src\Smith\Smith.csproj", "{728856C5-A241-6AD6-5CDE-1991FE2F10D7}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Demos", "Demos", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostedDemo", "src\HostedDemo\HostedDemo.csproj", "{7A572273-B346-1CA7-67AE-DC4906060B7A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SpectreDemo", "src\SpectreDemo\SpectreDemo.csproj", "{7C636145-4896-4FA2-BC7C-053AFDD6F6E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MCPDemo", "src\MCPDemo\MCPDemo.csproj", "{63E9B9A8-3EC5-4C0F-B8E5-30C475B2275E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,8 +23,28 @@ Global {728856C5-A241-6AD6-5CDE-1991FE2F10D7}.Debug|Any CPU.Build.0 = Debug|Any CPU {728856C5-A241-6AD6-5CDE-1991FE2F10D7}.Release|Any CPU.ActiveCfg = Release|Any CPU {728856C5-A241-6AD6-5CDE-1991FE2F10D7}.Release|Any CPU.Build.0 = Release|Any CPU + {7A572273-B346-1CA7-67AE-DC4906060B7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7A572273-B346-1CA7-67AE-DC4906060B7A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7A572273-B346-1CA7-67AE-DC4906060B7A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7A572273-B346-1CA7-67AE-DC4906060B7A}.Release|Any CPU.Build.0 = Release|Any CPU + {7C636145-4896-4FA2-BC7C-053AFDD6F6E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C636145-4896-4FA2-BC7C-053AFDD6F6E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C636145-4896-4FA2-BC7C-053AFDD6F6E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C636145-4896-4FA2-BC7C-053AFDD6F6E9}.Release|Any CPU.Build.0 = Release|Any CPU + {63E9B9A8-3EC5-4C0F-B8E5-30C475B2275E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {63E9B9A8-3EC5-4C0F-B8E5-30C475B2275E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {63E9B9A8-3EC5-4C0F-B8E5-30C475B2275E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {63E9B9A8-3EC5-4C0F-B8E5-30C475B2275E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7A572273-B346-1CA7-67AE-DC4906060B7A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {7C636145-4896-4FA2-BC7C-053AFDD6F6E9} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {63E9B9A8-3EC5-4C0F-B8E5-30C475B2275E} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {D5E491DA-619A-457D-BEBE-A137B0D9F527} + EndGlobalSection EndGlobal diff --git a/readme.md b/readme.md index c944b01..2cbb82f 100644 --- a/readme.md +++ b/readme.md @@ -47,15 +47,20 @@ Example using Claude: ```csharp #:package Smith@0.* -var chat = new Anthropic.AnthropicClient(Throw. - configuration["Claude:Key"] ?? throw new InvalidOperationException("Missing Claude:Key configuration."), +var client = new Anthropic.AnthropicClient(Throw. + Env.Get("Claude:Key") ?? throw new InvalidOperationException("Missing Claude:Key configuration."), services.GetRequiredService().CreateClient("ai"))) .UseLogging() .UseFunctionInvocation(); -var provider = services.BuildServiceProvider(); +var builder = App.CreateBuilder(args); +builder.Services.AddChatClient(new ); + +var app = builder.Build(); + var history = new List { new ChatMessage(ChatRole.System, Prompts.System) }; -var chat = provider.GetRequiredService(); +var chat = app.Services.GetRequiredService(); + var options = new ChatOptions { ModelId = "claude-sonnet-4-20250514", diff --git a/samples/.github/chatmodes/math.chatmode.md b/samples/.github/chatmodes/math.chatmode.md new file mode 100644 index 0000000..43069aa --- /dev/null +++ b/samples/.github/chatmodes/math.chatmode.md @@ -0,0 +1,5 @@ +--- +description: 'An Ask mode for math-related queries, which can render LaTeX equations.' +tools: ['smith-mcp'] +--- +Actively use the #latex_to_image_markdown tool to render LaTeX equations in your responses as inline markdown images to enhance clarity and visual appeal. This tool is particularly useful for displaying mathematical equations, formulas, and other LaTeX-rendered content in a visually engaging manner. \ No newline at end of file diff --git a/samples/.github/prompts/latex.prompt.md b/samples/.github/prompts/latex.prompt.md new file mode 100644 index 0000000..8cd4fe3 --- /dev/null +++ b/samples/.github/prompts/latex.prompt.md @@ -0,0 +1,4 @@ +--- +mode: ask +--- +Showcase popular equations in LaTeX format, and ensure they are rendered as images in the response by using the `latex_to_image_markdown` tool. \ No newline at end of file diff --git a/samples/appsettings.json b/samples/appsettings.json new file mode 100644 index 0000000..996d9e7 --- /dev/null +++ b/samples/appsettings.json @@ -0,0 +1,13 @@ +{ + "logging": { + "logLevel": { + "default": "Information", + "Polly": "Warning", + "System.Net.Http.HttpClient": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Extensions": "Warning", + "Microsoft.Azure": "Warning", + "Microsoft.Hosting": "Warning" + } + } +} \ No newline at end of file diff --git a/samples/latex.cs b/samples/latex.cs new file mode 100644 index 0000000..7bb7953 --- /dev/null +++ b/samples/latex.cs @@ -0,0 +1,66 @@ +#:package Smith@0.2.3 +#:package ModelContextProtocol@0.3.0-preview.* +#:package Microsoft.Extensions.Http@9.* +#:package SixLabors.ImageSharp@3.1.* + +using Smith; +using System.ComponentModel; +using ModelContextProtocol.Server; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); +builder.Services.AddHttpClient(); +builder.Logging.AddConsole(consoleLogOptions => +{ +// Configure all logs to go to stderr +consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + +await builder.Build().RunAsync(); + +[McpServerToolType] +public class LaTeX(IHttpClientFactory httpFactory) +{ + [McpServerTool, Description("Converts LaTeX equations into markdown-formatted images for display inline.")] + public async Task LatexToImageMarkdown( + [Description("The LaTeX equation to render.")] string latex, + [Description("Use dark mode by inverting the colors in the output.")] bool darkMode) + { + var colors = darkMode ? @"\bg{black}\fg{white}" : @"\bg{white}\fg{black}"; + var query = WebUtility.UrlEncode(@"\small\dpi{300}" + colors + latex); + var url = $"https://latex.codecogs.com/png.image?{query}"; + using var client = httpFactory.CreateClient(); + using var response = await client.GetAsync(url); + + if (response.IsSuccessStatusCode) + { + using var image = Image.Load(await response.Content.ReadAsStreamAsync()); + using var ms = new MemoryStream(); + image.SaveAsPng(ms); + var base64 = Convert.ToBase64String(ms.ToArray()); + return + $""" + ![{latex}]( + data:image/png;base64,{base64} + ) + """; + } + else + { + return + $""" + ```latex + {latex} + ``` + > {response.ReasonPhrase} + """; + } + } +} \ No newline at end of file diff --git a/src/HostedDemo/HostedDemo.csproj b/src/HostedDemo/HostedDemo.csproj new file mode 100644 index 0000000..e089978 --- /dev/null +++ b/src/HostedDemo/HostedDemo.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net10.0 + + + + + + + + + + + + + + + + diff --git a/src/HostedDemo/Program.cs b/src/HostedDemo/Program.cs new file mode 100644 index 0000000..3b5f37a --- /dev/null +++ b/src/HostedDemo/Program.cs @@ -0,0 +1,72 @@ +using Anthropic; + +const string Instructions = + """ + Your responses will be rendered using Spectre.Console.AnsiConsole.Write(new Markup(string text))). + This means that you can use rich text formatting, colors, and styles in your responses, but you must + ensure that the text is valid markup syntax. + """; + +var builder = App.CreateBuilder(args); +builder.Services.AddHttpClient(); + +builder.Services + .AddChatClient(services => new AnthropicClient( + Throw.IfNullOrEmpty(Env.Get("ANTHROPIC_KEY")), + services.GetRequiredService().CreateClient())) + .UseLogging() + .UseFunctionInvocation(); + +var app = builder.Build(async (IChatClient chat, CancellationToken token) => +{ + var history = new List { new(ChatRole.System, Instructions) }; + var options = new ChatOptions + { + ModelId = "claude-sonnet-4-20250514", + MaxOutputTokens = 1000, + Temperature = 0.7f, + Tools = [AIFunctionFactory.Create(() => DateTime.Now, "get_datetime", "Gets the current date and time on the user's local machine.")] + }; + + AnsiConsole.MarkupLine($":robot: Ready"); + AnsiConsole.Markup($":person_beard: "); + while (!token.IsCancellationRequested) + { + var input = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(input)) + continue; + + history.Add(new ChatMessage(ChatRole.User, input)); + try + { + var response = await AnsiConsole.Status().StartAsync(":robot: Thinking...", + ctx => chat.GetResponseAsync(input, options)); + + history.AddRange(response.Messages); + try + { + // Try rendering as formatted markup + if (response.Text is { Length: > 0 }) + AnsiConsole.MarkupLine($":robot: {response.Text}"); + } + catch (Exception) + { + // Fallback to escaped markup text if rendering fails + AnsiConsole.MarkupLineInterpolated($":robot: {response.Text}"); + } + + AnsiConsole.Markup($":person_beard: "); + } + catch (Exception e) + { + AnsiConsole.WriteException(e); + } + } + + AnsiConsole.MarkupLine($":robot: Shutting down..."); +}); + +Console.WriteLine("Powered by Smith"); + + +await app.RunAsync(); \ No newline at end of file diff --git a/src/HostedDemo/appsettings.json b/src/HostedDemo/appsettings.json new file mode 100644 index 0000000..996d9e7 --- /dev/null +++ b/src/HostedDemo/appsettings.json @@ -0,0 +1,13 @@ +{ + "logging": { + "logLevel": { + "default": "Information", + "Polly": "Warning", + "System.Net.Http.HttpClient": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Extensions": "Warning", + "Microsoft.Azure": "Warning", + "Microsoft.Hosting": "Warning" + } + } +} \ No newline at end of file diff --git a/src/MCPDemo/MCPDemo.csproj b/src/MCPDemo/MCPDemo.csproj new file mode 100644 index 0000000..96871a8 --- /dev/null +++ b/src/MCPDemo/MCPDemo.csproj @@ -0,0 +1,23 @@ + + + + + + Exe + net10.0 + + + + + + + + + + + + + + + + diff --git a/src/MCPDemo/Program.cs b/src/MCPDemo/Program.cs new file mode 100644 index 0000000..98ef84f --- /dev/null +++ b/src/MCPDemo/Program.cs @@ -0,0 +1,58 @@ +using ModelContextProtocol.Server; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +var builder = App.CreateBuilder(args); +builder.Services.AddHttpClient(); +builder.Logging.AddConsole(consoleLogOptions => +{ + // Configure all logs to go to stderr + consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Services + .AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(); + +await builder.Build().RunAsync(); + +[McpServerToolType] +public class LaTeX(IHttpClientFactory httpFactory) +{ + [McpServerTool, Description("Converts LaTeX equations into markdown-formatted images for display inline.")] + public async Task LatexToImageMarkdown( + [Description("The LaTeX equation to render.")] string latex, + [Description("Use dark mode by inverting the colors in the output.")] bool darkMode) + { + var colors = darkMode ? @"\bg{black}\fg{white}" : @"\bg{white}\fg{black}"; + var query = WebUtility.UrlEncode(@"\small\dpi{300}" + colors + latex); + var url = $"https://latex.codecogs.com/png.image?{query}"; + using var client = httpFactory.CreateClient(); + using var response = await client.GetAsync(url); + + if (response.IsSuccessStatusCode) + { + using var image = Image.Load(await response.Content.ReadAsStreamAsync()); + using var ms = new MemoryStream(); + image.SaveAsPng(ms); + var base64 = Convert.ToBase64String(ms.ToArray()); + return + $""" + ![{latex}]( + data:image/png;base64,{base64} + ) + """; + } + else + { + return + $""" + ```latex + {latex} + ``` + > {response.ReasonPhrase} + """; + } + } +} \ No newline at end of file diff --git a/src/MCPDemo/appsettings.json b/src/MCPDemo/appsettings.json new file mode 100644 index 0000000..996d9e7 --- /dev/null +++ b/src/MCPDemo/appsettings.json @@ -0,0 +1,13 @@ +{ + "logging": { + "logLevel": { + "default": "Information", + "Polly": "Warning", + "System.Net.Http.HttpClient": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.Extensions": "Warning", + "Microsoft.Azure": "Warning", + "Microsoft.Hosting": "Warning" + } + } +} \ No newline at end of file diff --git a/src/Smith/App.cs b/src/Smith/App.cs new file mode 100644 index 0000000..1d9fd47 --- /dev/null +++ b/src/Smith/App.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Smith; + +/// +/// Main entry point for the Smith application. +/// +public static class App +{ + /// + /// Invokes the with additional pre-configured defaults. + /// + /// + /// The following defaults are applied to the returned : + /// + /// Load environment variables from .env files in current dir and above, and user profile dir. + /// + /// + /// The command line args. + /// The initialized . + public static HostApplicationBuilder CreateBuilder(string[]? args) => Host.CreateApplicationBuilder(args); + + /// + /// Builds the host app and registers the provided function as a hosted service to be + /// executed automatically when the host is run. + /// + public static IHost Build(this HostApplicationBuilder builder, Func main) + { + builder.Services.AddHostedService(sp => new AnonymousHostedService(main, sp.GetRequiredService())); + return builder.Build(); + } + + /// + /// Builds the host app and registers the provided function as a hosted service to be + /// executed automatically when the host is run. + /// + public static IHost Build(this HostApplicationBuilder builder, Func main) where TDependency : notnull + { + builder.Services.AddHostedService(sp => new AnonymousHostedService(main, sp.GetRequiredService(), sp.GetRequiredService())); + return builder.Build(); + } + + class AnonymousHostedService(Func run, IHostApplicationLifetime host) : IHostedService + { + Task? main; + + public Task StartAsync(CancellationToken token) + { + main = Task.Run(() => run(host.ApplicationStopping), host.ApplicationStopped); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken token) + { + if (main != null) + await main.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + class AnonymousHostedService(Func run, TDependency dependency, IHostApplicationLifetime host) : IHostedService + { + Task? main; + + public Task StartAsync(CancellationToken token) + { + main = Task.Run(() => run(dependency, host.ApplicationStopping), host.ApplicationStopped); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken token) + { + if (main != null) + await main.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + +} diff --git a/src/Smith/Env.cs b/src/Smith/Env.cs index 3713f37..ec31fa3 100644 --- a/src/Smith/Env.cs +++ b/src/Smith/Env.cs @@ -6,7 +6,7 @@ namespace Smith; /// /// Allows retrieving configuration settings for the app environment using the -/// default configuration system provided by +/// default configuration system provided by . /// public static class Env { @@ -27,4 +27,4 @@ public static class Env /// Never returns null if is not . [return: NotNullIfNotNull(nameof(defaultValue))] public static string? Get(string key, string defaultValue) => configuration[key] ?? defaultValue; -} +} \ No newline at end of file diff --git a/src/Smith/HostExtensions.cs b/src/Smith/HostExtensions.cs index 78a6c98..e292e83 100644 --- a/src/Smith/HostExtensions.cs +++ b/src/Smith/HostExtensions.cs @@ -10,37 +10,11 @@ namespace Smith; /// public static class HostExtensions { - extension(TBuilder builder) where TBuilder : IHostApplicationBuilder - { - /// - /// Configures the host configuration. - /// - public TBuilder ConfigureAppConfiguration(Action configure) - { - configure?.Invoke(builder.Configuration); - return builder; - } - - /// - /// Configures services in the host. - /// - public TBuilder ConfigureServices(Action configure) - { - configure?.Invoke(builder.Services); - return builder; - } - } - extension(IHost app) { /// /// Gest the host app configuration; /// public IConfiguration Configuration => app.Services.GetRequiredService(); - - /// - /// Gets the default AI client for the app. - /// - public IChatClient ChatClient => app.Services.GetRequiredService(); } } diff --git a/src/Smith/Smith.csproj b/src/Smith/Smith.csproj index 8934ec2..22ef719 100644 --- a/src/Smith/Smith.csproj +++ b/src/Smith/Smith.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Smith/Smith.props b/src/Smith/Smith.props index 3cc28c7..5e5c4b0 100644 --- a/src/Smith/Smith.props +++ b/src/Smith/Smith.props @@ -7,6 +7,7 @@ + @@ -15,11 +16,12 @@ + - + diff --git a/src/SpectreDemo/Program.cs b/src/SpectreDemo/Program.cs new file mode 100644 index 0000000..31e1d5a --- /dev/null +++ b/src/SpectreDemo/Program.cs @@ -0,0 +1,59 @@ +using Anthropic; + +const string Instructions = + """ + Your responses will be rendered using Spectre.Console.AnsiConsole.Write(new Markup(string text))). + This means that you can use rich text formatting, colors, and styles in your responses, but you must + ensure that the text is valid markup syntax. + """; + +var chat = new AnthropicClient( + Throw.IfNullOrEmpty(Env.Get("ANTHROPIC_KEY")), + new HttpClient()) + .AsBuilder() + .UseFunctionInvocation() + .Build(); + +var history = new List { new(ChatRole.System, Instructions) }; +var options = new ChatOptions +{ + ModelId = "claude-sonnet-4-20250514", + MaxOutputTokens = 1000, + Temperature = 0.7f, + Tools = [AIFunctionFactory.Create(() => DateTime.Now, "get_datetime", "Gets the current date and time on the user's local machine.")] +}; + +AnsiConsole.MarkupLine($":robot: Ready"); +AnsiConsole.Markup($":person_beard: "); +while (true) +{ + var input = Console.ReadLine()?.Trim(); + if (string.IsNullOrEmpty(input)) + continue; + + history.Add(new ChatMessage(ChatRole.User, input)); + try + { + var response = await AnsiConsole.Status().StartAsync(":robot: Thinking...", + ctx => chat.GetResponseAsync(input, options)); + + history.AddRange(response.Messages); + try + { + // Try rendering as formatted markup + if (response.Text is { Length: > 0 }) + AnsiConsole.MarkupLine($":robot: {response.Text}"); + } + catch (Exception) + { + // Fallback to escaped markup text if rendering fails + AnsiConsole.MarkupLineInterpolated($":robot: {response.Text}"); + } + + AnsiConsole.Markup($":person_beard: "); + } + catch (Exception e) + { + AnsiConsole.WriteException(e); + } +} \ No newline at end of file diff --git a/src/SpectreDemo/SpectreDemo.csproj b/src/SpectreDemo/SpectreDemo.csproj new file mode 100644 index 0000000..46ffab3 --- /dev/null +++ b/src/SpectreDemo/SpectreDemo.csproj @@ -0,0 +1,22 @@ + + + + + + Exe + net10.0 + + + + + + + + + + + + + + +