diff --git a/.github/workflows/ci-ai.yml b/.github/workflows/ci-ai.yml new file mode 100644 index 000000000..ed0cbb386 --- /dev/null +++ b/.github/workflows/ci-ai.yml @@ -0,0 +1,36 @@ +name: CI - AI Extensions + +on: + push: + branches: [main, mattleibow/ai-annotations] + paths: + - 'src/AIExtensions/**' + - 'tests/AIExtensions/**' + - 'eng/**' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'global.json' + - 'NuGet.config' + pull_request: + types: [opened, synchronize, reopened, edited] + branches: [main, mattleibow/ai-annotations] + paths: + - 'src/AIExtensions/**' + - 'tests/AIExtensions/**' + - 'eng/**' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'global.json' + - 'NuGet.config' + +jobs: + build: + uses: ./.github/workflows/_build.yml + with: + project-path: src/AIExtensions/AIExtensions.slnf + project-name: ai-extensions + run-tests: true + pack: true + install-workloads: false diff --git a/Directory.Build.props b/Directory.Build.props index 994a2a147..af8305f71 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,6 +11,7 @@ https://github.com/dotnet/maui-labs https://github.com/dotnet/maui-labs false + false true diff --git a/Directory.Packages.props b/Directory.Packages.props index 7da8ccd4f..bdecf324e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,14 +24,28 @@ - + + + + + + + + + + + + + + + + - @@ -63,17 +77,14 @@ - - - - + @@ -93,6 +104,8 @@ + + diff --git a/MauiLabs.slnx b/MauiLabs.slnx index 576edb0c8..50e0a6847 100644 --- a/MauiLabs.slnx +++ b/MauiLabs.slnx @@ -8,6 +8,9 @@ + + + @@ -17,6 +20,14 @@ + + + + + + + + diff --git a/README.md b/README.md index c9abc1d49..1c4f66923 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,26 @@ A comprehensive MAUI testing, automation, and debugging toolkit. The DevFlow CLI | `Microsoft.Maui.DevFlow.Driver` | Platform driver library | | `Microsoft.Maui.DevFlow.Logging` | Buffered JSONL file logger | +### AI Extensions + +AI integration packages for `Microsoft.Extensions.AI` and .NET MAUI apps. + +#### AI Attributes + +Source-generated AI tool discovery — annotate methods or property accessors with `[ExportAIFunction]` to create AI-callable tools. Composed or auto-generated tool contexts, DI-aware parameter binding, approval gates, AOT-friendly. + +| Package | Description | +|---------|-------------| +| `Microsoft.Maui.AI.Attributes` | Source-generated AI tool contexts for `Microsoft.Extensions.AI` | + +#### AI Navigation + +Runtime Shell route discovery and template-aware navigation for AI agents. Clean URIs like `//main/products/product/seed-tomato/review` are automatically parsed into multi-step `GoToAsync` calls with extracted parameters. + +| Package | Description | +|---------|-------------| +| `Microsoft.Maui.AI.Navigation` | Runtime Shell route discovery and AI-friendly navigation | + ### macOS AppKit Backend A native macOS AppKit backend for .NET MAUI — run MAUI apps as true AppKit apps with NSWindow, NSButton, NSScrollView, native menu bar, sidebar flyout, and more. An alternative to Mac Catalyst. @@ -168,6 +188,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for build instructions and development se For the formal DevFlow HTTP and WebSocket contract, see [`docs/DevFlow/spec`](docs/DevFlow/spec/README.md). +For AI Extensions usage and samples, see [`src/AIExtensions/README.md`](src/AIExtensions/README.md) and [`samples/AIExtensions.Sample.Garden`](samples/AIExtensions.Sample.Garden/README.md). + ## Support See [SUPPORT.md](.github/SUPPORT.md) for how to file issues, get help, and the support policy for this repository. diff --git a/eng/Versions.props b/eng/Versions.props index 2621e9927..437b601d7 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -29,15 +29,26 @@ 10.0.5 10.0.5 10.0.5 + 10.0.5 10.0.5 10.0.5 + 10.0.5 + 10.0.5 10.0.5 + + 10.4.1 + 10.4.1 + 2.1.0 + + + 3.11.0 + 5.0.0 + 8.4.2 1.3.1 - 4.12.0 3.119.2 0.54.0 2.0.5 @@ -71,8 +82,7 @@ 1 - 10.3.0 - 10.3.0 + 10.4.1 1.0.0-rc2 1.0.0-rc2 @@ -86,6 +96,8 @@ 2.9.3 3.1.5 8.0.0 + 28.15.0 + 2.5.0 10.0.201 diff --git a/eng/pipelines/devflow-official.yml b/eng/pipelines/devflow-official.yml index 0ed97dd98..f49a5e9f8 100644 --- a/eng/pipelines/devflow-official.yml +++ b/eng/pipelines/devflow-official.yml @@ -36,6 +36,10 @@ parameters: displayName: 'Publish macOS AppKit packages to NuGet.org' type: boolean default: false +- name: publishAIExtensionsNuget + displayName: 'Publish AI Extensions packages to NuGet.org' + type: boolean + default: false - name: publishEssentialsAINuget displayName: 'Publish EssentialsAI packages to NuGet.org' type: boolean @@ -141,6 +145,30 @@ extends: $(_OfficialBuildArgs) displayName: Build and Test Cli + - job: AI + displayName: AI Extensions - Windows + pool: + name: NetCore1ESPool-Internal + demands: ImageOverride -equals windows.vs2026preview.scout.amd64 + strategy: + matrix: + Release: + _BuildConfig: Release + _OfficialBuildArgs: /p:DotNetSignType=$(_SignType) + /p:TeamName=$(_TeamName) + /p:OfficialBuildId=$(BUILD.BUILDNUMBER) + steps: + - task: UseDotNet@2 + displayName: Install .NET SDK + inputs: + version: 10.0.105 + - script: eng\common\cibuild.cmd + -configuration $(_BuildConfig) + -prepareMachine + -projects $(Build.SourcesDirectory)\src\AIExtensions\AIExtensions.slnf + $(_OfficialBuildArgs) + displayName: Build and Test AI Extensions + - job: AppProjectReference displayName: AppProjectReference - Windows pool: @@ -587,6 +615,61 @@ extends: nuGetFeedType: external publishFeedCredentials: 'nuget.org (dotnetframework)' + # Publish AI Extensions packages to NuGet.org + - ${{ if eq(parameters.publishAIExtensionsNuget, true) }}: + - stage: publish_ai_extensions_nuget + displayName: 'Publish AI Extensions to NuGet.org' + dependsOn: + - Validate + - publish_using_darc + jobs: + - job: PrepareArtifacts + displayName: 'Prepare AI Artifacts' + timeoutInMinutes: 15 + pool: + name: NetCore1ESPool-Internal + image: windows.vs2026preview.scout.amd64 + os: windows + templateContext: + outputs: + - output: pipelineArtifact + displayName: Publish AI Packages + targetPath: '$(Pipeline.Workspace)/AIPackages' + artifactName: AIPackagesForNuGet + steps: + - download: current + artifact: PackageArtifacts + displayName: Download PackageArtifacts + - powershell: | + New-Item -ItemType Directory -Force -Path '$(Pipeline.Workspace)/AIPackages' + Copy-Item '$(Pipeline.Workspace)/PackageArtifacts/Microsoft.Maui.AI.*.nupkg' '$(Pipeline.Workspace)/AIPackages/' -Verbose + displayName: Filter AI packages + + - job: PublishNuGet + displayName: 'Push AI to NuGet.org' + dependsOn: PrepareArtifacts + timeoutInMinutes: 30 + pool: + name: NetCore1ESPool-Internal + image: windows.vs2026preview.scout.amd64 + os: windows + templateContext: + type: releaseJob + isProduction: true + inputs: + - input: pipelineArtifact + artifactName: AIPackagesForNuGet + targetPath: '$(Pipeline.Workspace)/AIPackages' + steps: + - task: 1ES.PublishNuget@1 + displayName: 'Push AI to NuGet.org' + inputs: + useDotNetTask: false + packagesToPush: '$(Pipeline.Workspace)/AIPackages/*.nupkg' + packageParentPath: '$(Pipeline.Workspace)/AIPackages' + nuGetFeedType: external + publishFeedCredentials: 'nuget.org (dotnetframework)' + # Publish WPF packages to NuGet.org - ${{ if eq(parameters.publishWpfNuget, true) }}: - stage: publish_wpf_nuget diff --git a/samples/AIExtensions.Sample.DIParameters/AIExtensions.Sample.DIParameters.csproj b/samples/AIExtensions.Sample.DIParameters/AIExtensions.Sample.DIParameters.csproj new file mode 100644 index 000000000..8afe0bd1d --- /dev/null +++ b/samples/AIExtensions.Sample.DIParameters/AIExtensions.Sample.DIParameters.csproj @@ -0,0 +1,31 @@ + + + + Exe + net10.0 + AIExtensions.Sample.DIParameters + enable + enable + ai-attributes-secrets + + + true + + + + + + + + + + + + + + + + + diff --git a/samples/AIExtensions.Sample.DIParameters/ModelProviders.cs b/samples/AIExtensions.Sample.DIParameters/ModelProviders.cs new file mode 100644 index 000000000..170590e04 --- /dev/null +++ b/samples/AIExtensions.Sample.DIParameters/ModelProviders.cs @@ -0,0 +1,20 @@ +namespace AIExtensions.Sample.DIParameters; + +/// +/// A keyed service. Pulled from DI using [FromKeyedServices("premium")] +/// or [FromKeyedServices("free")]. +/// +public interface IModelProvider +{ + string Name { get; } +} + +public sealed class FreeModelProvider : IModelProvider +{ + public string Name => "free-v1"; +} + +public sealed class PremiumModelProvider : IModelProvider +{ + public string Name => "premium-v2"; +} diff --git a/samples/AIExtensions.Sample.DIParameters/Program.cs b/samples/AIExtensions.Sample.DIParameters/Program.cs new file mode 100644 index 000000000..a0313155e --- /dev/null +++ b/samples/AIExtensions.Sample.DIParameters/Program.cs @@ -0,0 +1,114 @@ +using System.ClientModel; +using AIExtensions; +using AIExtensions.Sample.DIParameters; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Maui.AI.Attributes; + +// This sample focuses on the parameter binding shapes Microsoft.Maui.AI.Attributes +// supports — the stuff that makes it more than "attributes wrapping ReflectionAIFunction": +// +// • [FromServices] for DI-resolved parameters +// • [FromKeyedServices] for keyed services +// • CancellationToken as a direct parameter +// • plain records / primitives bind from the argument dictionary (no annotation) +// +// See TranslatorService.Translate for the single tool. + +var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + +var apiKey = configuration["AI:ApiKey"]; +var endpoint = configuration["AI:Endpoint"]; +var deployment = configuration["AI:DeploymentName"]; + +if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(deployment)) +{ + Console.Error.WriteLine(""" + AI:Endpoint, AI:ApiKey and AI:DeploymentName must be set. Configure user-secrets: + + dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" "" + dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" "" + dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" "" + + (shared across all 4 AI.Attributes samples) + """); + return 1; +} + +var services = new ServiceCollection(); + +// Logging. PigLatinTranslator takes an ILogger in its constructor — this +// demonstrates nested DI: the [FromServices] ITranslator parameter resolves +// to PigLatinTranslator, which in turn needs a logger. +services.AddLogging(b => + b.AddSimpleConsole(o => + { + o.SingleLine = true; + o.IncludeScopes = false; + o.TimestampFormat = null; + })); + +// Backing service that owns the [ExportAIFunction] method. +services.AddSingleton(); + +// [FromServices] ITranslator resolves to this implementation. It in turn takes +// ILogger in its constructor, so the full dependency chain +// is: tool method → ITranslator → ILogger. +services.AddSingleton(); + +// Keyed DI: [FromKeyedServices("premium")] pulls this specific instance. +services.AddKeyedSingleton("premium"); +services.AddKeyedSingleton("free"); + +// Tool composition — no DI registration needed; the source generator emits +// a static Default singleton. Each generated tool reads AIFunctionArguments.Services +// at invocation time, which ChatClientBuilder.UseFunctionInvocation().Build(sp) +// populates with the root provider automatically. + +// Chat client. +var azure = new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey)); +services.AddSingleton(azure.GetChatClient(deployment).AsIChatClient()); + +var root = services.BuildServiceProvider(); +var chat = new ChatClientBuilder(root.GetRequiredService()) + .UseFunctionInvocation() + .Build(root); + +var tools = AIExtensionsSampleDIParametersToolContext.Default.Tools; +var options = new ChatOptions { Tools = [.. tools] }; + +Console.WriteLine($"{tools.Count} tool(s) registered:"); +foreach (var t in tools) + Console.WriteLine($" - {t.Name}: {t.Description}"); +Console.WriteLine(); +Console.WriteLine("Try: \"Translate 'hello world' to pig latin with verbose output.\""); +Console.WriteLine("Ctrl+C to exit."); +Console.WriteLine(); + +var history = new List +{ + new(ChatRole.System, + """ + You are a translation assistant. You MUST call the translate tool for every + translation request, even trivial ones — never translate text yourself. + """) +}; + +while (true) +{ + Console.Write("> "); + var input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + history.Add(new ChatMessage(ChatRole.User, input)); + var response = await chat.GetResponseAsync(history, options); + history.AddMessages(response); + Console.WriteLine(response.Text); + Console.WriteLine(); +} diff --git a/samples/AIExtensions.Sample.DIParameters/README.md b/samples/AIExtensions.Sample.DIParameters/README.md new file mode 100644 index 000000000..f1319aad0 --- /dev/null +++ b/samples/AIExtensions.Sample.DIParameters/README.md @@ -0,0 +1,52 @@ +# DIParameters — parameter binding shapes + +A console app that shows the parameter shapes +AI Extensions supports beyond "plain value in, value out". + +## What this demonstrates + +All on a single `[ExportAIFunction]` method: + +- **`[FromServices]`.** An `ITranslator translator` parameter is resolved from the + service provider at invocation time. The AI never sees it in the schema. +- **Keyed DI.** `[FromKeyedServices("premium")] IModelProvider model` pulls + the keyed registration. +- **Plain record argument.** `TranslationOptions` has no attribute — the generator + treats it as a normal model-filled argument because it isn't marked for DI. +- **`CancellationToken`.** Bound automatically; never in the schema. + +## Why this exists + +Other attribute-based libraries typically only wrap `ReflectionAIFunction` +and expect you to hand-author `AIFunctionFactory.Create` calls to get DI +support. This sample is the proof the source generator emits the same +behaviour without runtime reflection — and adds first-class +`[FromServices]`/`[FromKeyedServices]` attribute support on top. + +## Run + +All four `AIExtensions.Sample.*` apps share one `UserSecretsId` +(`ai-attributes-secrets`), so you configure the endpoint once: + +```bash +dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" "https://.openai.azure.com" +dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" "" +dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" "" + +dotnet run --project samples/AIExtensions.Sample.DIParameters +``` + +Try: `Translate 'hello world' to pig latin with verbose output.` + +## Inspecting the generated source + +This csproj sets `EmitCompilerGeneratedFiles=true` so you can see exactly +what `Microsoft.Maui.AI.Attributes.Generators` emits for each tool context. +This is **not required** to run the sample. If you do not need to inspect +the generated source, you can remove the property from the csproj. + +After a build, look under: + +``` +artifacts/obj////generated/Microsoft.Maui.AI.Attributes.Generators/Microsoft.Maui.AI.Attributes.Generators.AIToolContextGenerator/*.g.cs +``` diff --git a/samples/AIExtensions.Sample.DIParameters/TranslatorService.cs b/samples/AIExtensions.Sample.DIParameters/TranslatorService.cs new file mode 100644 index 000000000..82be925c4 --- /dev/null +++ b/samples/AIExtensions.Sample.DIParameters/TranslatorService.cs @@ -0,0 +1,49 @@ +using System.ComponentModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Maui.AI.Attributes; + +namespace AIExtensions.Sample.DIParameters; + +/// +/// Options the AI model supplies as part of the tool call. A plain record +/// (not an interface/abstract class) so the generator schemas it by default +/// — no explicit attribute is needed to keep it in the tool schema. +/// +public sealed record TranslationOptions( + bool Verbose = false); + +public class TranslatorService(ILogger logger) +{ + [Description("Translates a phrase using the configured translator.")] + [ExportAIFunction("translate")] + public string Translate( + [Description("The text to translate")] string text, + // Explicit DI: [FromServices] resolves from IServiceProvider and + // excludes the parameter from the tool schema. + [FromServices] ITranslator translator, + // Explicit keyed DI: resolved via [FromKeyedServices]. + [FromKeyedServices("premium")] IModelProvider model, + // A plain record — no attribute needed. The AI fills it in per call. + TranslationOptions options, + // Direct CancellationToken support — never appears in the schema. + CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + // Log what DI resolved and what the model filled in, so the sample + // demonstrates parameter binding even when the LLM paraphrases the + // tool result. + if (options.Verbose) + { + logger.LogInformation( + "translate tool called: text={Text}, translator={Translator}, model={Model}, options={Options}", + text, + translator.GetType().Name, + model.Name, + options); + } + + return translator.Translate(text, options.Verbose); + } +} diff --git a/samples/AIExtensions.Sample.DIParameters/Translators.cs b/samples/AIExtensions.Sample.DIParameters/Translators.cs new file mode 100644 index 000000000..2e17266f3 --- /dev/null +++ b/samples/AIExtensions.Sample.DIParameters/Translators.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.Logging; + +namespace AIExtensions.Sample.DIParameters; + +/// +/// A translator service. Resolved via DI — the [FromServices] attribute +/// on the parameter tells the generator to inject it from the service provider +/// at invocation time rather than expecting the AI model to supply it. +/// +public interface ITranslator +{ + string Translate(string text, bool verbose = false); +} + +/// +/// Takes as a constructor dependency — this exercises +/// nested DI: the tool method asks for ITranslator, which the +/// container resolves to this type, which in turn needs a logger. The logger +/// is produced by the standard AddLogging() registration and appears +/// in the console output, making the resolution chain visible. +/// +public sealed class PigLatinTranslator(ILogger logger) : ITranslator +{ + public string Translate(string text, bool verbose = false) + { + var words = text.Split(' '); + + if (verbose) + { + logger.LogInformation( + "Translating {WordCount} word(s) to pig latin...", + words.Length); + } + + return string.Join(' ', words.Select(w => + w.Length > 1 ? w[1..] + w[0] + "ay" : w)); + } +} diff --git a/samples/AIExtensions.Sample.Garden/AIExtensions.Sample.Garden.csproj b/samples/AIExtensions.Sample.Garden/AIExtensions.Sample.Garden.csproj new file mode 100644 index 000000000..37e10e986 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/AIExtensions.Sample.Garden.csproj @@ -0,0 +1,68 @@ + + + + net10.0-android;net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + Exe + AIExtensions.Sample.Garden + true + true + + Garden AI Sample + com.microsoft.maui.aiattributes.sample.garden + 1.0 + 1 + + None + + 10.0.17763.0 + + ai-attributes-secrets + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([System.Environment]::GetFolderPath(SpecialFolder.UserProfile))\AppData\Roaming\Microsoft\UserSecrets\$(UserSecretsId)\secrets.json + $([System.Environment]::GetFolderPath(SpecialFolder.UserProfile))/.microsoft/usersecrets/$(UserSecretsId)/secrets.json + + + + + + + diff --git a/samples/AIExtensions.Sample.Garden/App.xaml b/samples/AIExtensions.Sample.Garden/App.xaml new file mode 100644 index 000000000..971c2247a --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/samples/AIExtensions.Sample.Garden/App.xaml.cs b/samples/AIExtensions.Sample.Garden/App.xaml.cs new file mode 100644 index 000000000..d43108dff --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/App.xaml.cs @@ -0,0 +1,14 @@ +namespace AIExtensions.Sample.Garden; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new AppShell()); + } +} diff --git a/samples/AIExtensions.Sample.Garden/AppShell.xaml b/samples/AIExtensions.Sample.Garden/AppShell.xaml new file mode 100644 index 000000000..5417b0b58 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/AppShell.xaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/samples/AIExtensions.Sample.Garden/AppShell.xaml.cs b/samples/AIExtensions.Sample.Garden/AppShell.xaml.cs new file mode 100644 index 000000000..087668291 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/AppShell.xaml.cs @@ -0,0 +1,20 @@ +using AIExtensions.Sample.Garden.Pages; + +namespace AIExtensions.Sample.Garden; + +public partial class AppShell : Shell +{ + public AppShell() + { + InitializeComponent(); + + // Detail pages under products — friendly URLs: /products/product?sku=X + Routing.RegisterRoute("product", typeof(ProductDetailPage)); + // Review modal — /products/product/review?sku=X + Routing.RegisterRoute("review", typeof(ProductReviewPage)); + // Order detail — /orders/order?orderId=X + Routing.RegisterRoute("order", typeof(OrderDetailPage)); + // Cart stays modal — slides up from anywhere + Routing.RegisterRoute("cart", typeof(CartPage)); + } +} diff --git a/samples/AIExtensions.Sample.Garden/FluentIcons.cs b/samples/AIExtensions.Sample.Garden/FluentIcons.cs new file mode 100644 index 000000000..eeeaa2eda --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/FluentIcons.cs @@ -0,0 +1,44 @@ +namespace AIExtensions.Sample.Garden; + +/// +/// Fluent UI System Icons (Filled) glyph constants. +/// Use with FontFamily="FluentFilled" in XAML or code. +/// +static class FluentIcons +{ + public const string Cart = "\uF26B"; + public const string Send = "\uF6A3"; + public const string Compose = "\uF315"; + public const string Dismiss = "\uF36A"; + public const string LeafOne = "\uE769"; + public const string LeafThree = "\uE76C"; + public const string Box = "\uE1D0"; + public const string ShoppingBag = "\uF788"; + public const string Receipt = "\uEA07"; + public const string Delete = "\uF34D"; + public const string Checkmark = "\uF295"; + public const string Home = "\uF488"; + public const string Sparkle = "\uEB3D"; + public const string Food = "\uF448"; + public const string Drop = "\uE59D"; + public const string Toolbox = "\uF848"; + public const string Star = "\uF719"; + public const string ChatSparkle = "\uF7ED"; + public const string Add = "\uF10A"; + + // Product category / item icons + public const string WeatherSunny = "\uF8BA"; + public const string BowlSalad = "\uEEE9"; + public const string Earth = "\uF3DA"; + public const string Beaker = "\uF1D8"; + public const string Wrench = "\uF8D9"; + public const string Cut = "\uF33B"; + public const string HandRight = "\uE6ED"; + public const string Temperature = "\uF7A8"; + public const string PaintBrush = "\uF59D"; + public const string LockClosed = "\uE79E"; + + // Chevrons (for expand/collapse and list navigation) + public const string ChevronRight = "\uF2B0"; + public const string ChevronDown = "\uF2A3"; +} diff --git a/samples/AIExtensions.Sample.Garden/IsNotNullConverter.cs b/samples/AIExtensions.Sample.Garden/IsNotNullConverter.cs new file mode 100644 index 000000000..adca35e40 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/IsNotNullConverter.cs @@ -0,0 +1,15 @@ +using System.Globalization; + +namespace AIExtensions.Sample.Garden; + +/// +/// Returns true if the value is not null (and not empty for strings). +/// +public sealed class IsNotNullConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + => value is string s ? !string.IsNullOrEmpty(s) : value is not null; + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); +} diff --git a/samples/AIExtensions.Sample.Garden/MauiProgram.cs b/samples/AIExtensions.Sample.Garden/MauiProgram.cs new file mode 100644 index 000000000..fb8a2b462 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/MauiProgram.cs @@ -0,0 +1,122 @@ +using System.ClientModel; +using System.Reflection; +using AIExtensions.Sample.Garden.Pages; +using AIExtensions.Sample.Garden.Services; +using AIExtensions.Sample.Garden.ViewModels; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Maui.AI.Navigation; +using Microsoft.Maui.DevFlow.Agent; + +namespace AIExtensions.Sample.Garden; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold"); + fonts.AddFont("FluentSystemIcons-Filled.ttf", "FluentFilled"); + }) + .ConfigureMauiHandlers(handlers => + { + // Remove native Entry border so our custom Border wrapper is the only visible frame. +#if IOS || MACCATALYST + Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoBorder", (handler, _) => + { + handler.PlatformView.BorderStyle = UIKit.UITextBorderStyle.None; + }); +#elif ANDROID + Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("NoBorder", (handler, _) => + { + handler.PlatformView.BackgroundTintList = Android.Content.Res.ColorStateList.ValueOf(Android.Graphics.Color.Transparent); + }); +#endif + }); + + builder.Configuration.AddUserSecrets(); + +#if DEBUG + builder.AddMauiDevFlowAgent(); +#endif + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.AddOpenAIServices(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddSingleton(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + builder.Services.AddTransient(); + +#if DEBUG + builder.Logging.AddDebug(); +#endif + + return builder.Build(); + } + + private static void AddUserSecrets(this ConfigurationManager manager) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceNames = assembly.GetManifestResourceNames(); + var secretsResource = resourceNames.FirstOrDefault(n => n.EndsWith("secrets.json")); + if (secretsResource is not null) + { + using var stream = assembly.GetManifestResourceStream(secretsResource); + if (stream is not null) + manager.AddJsonStream(stream); + } + } + + private static MauiAppBuilder AddOpenAIServices(this MauiAppBuilder builder) + { + var aiSection = builder.Configuration.GetSection("AI"); + var apiKey = aiSection["ApiKey"]; + var endpoint = aiSection["Endpoint"]; + var deploymentName = aiSection["DeploymentName"]; + + if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(endpoint) || string.IsNullOrEmpty(deploymentName)) + { + throw new InvalidOperationException( + """ + AI services are not configured. Set up user secrets (shared across all AIExtensions samples): + + dotnet user-secrets --id ai-attributes-secrets set "AI:Endpoint" "" + dotnet user-secrets --id ai-attributes-secrets set "AI:ApiKey" "" + dotnet user-secrets --id ai-attributes-secrets set "AI:DeploymentName" "" + """); + } + + var azureClient = new AzureOpenAIClient( + new Uri(endpoint), + new ApiKeyCredential(apiKey)); + var chatClient = azureClient.GetChatClient(deploymentName); + + builder.Services.AddSingleton(chatClient.AsIChatClient()); + + return builder; + } +} diff --git a/samples/AIExtensions.Sample.Garden/Messages/Messages.cs b/samples/AIExtensions.Sample.Garden/Messages/Messages.cs new file mode 100644 index 000000000..953998aff --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/Messages/Messages.cs @@ -0,0 +1,26 @@ +using AIExtensions.Sample.Garden.ViewModels; + +namespace AIExtensions.Sample.Garden.Messages; + +/// +/// Broadcast when the cart contents change (add, remove, clear, qty change). +/// +public sealed class CartChangedMessage; + +/// +/// Broadcast after the AI chat completes a full turn (response + tool calls). +/// +public sealed class ChatTurnCompletedMessage; + +/// +/// Broadcast when a new chat message is appended so views can scroll. +/// +public sealed class ChatMessageAddedMessage(ChatMessageViewModel message) +{ + public ChatMessageViewModel Message { get; } = message; +} + +/// +/// Request that the chat VM starts a fresh session (clears history + messages). +/// +public sealed class StartNewChatSessionMessage; diff --git a/samples/AIExtensions.Sample.Garden/Models/ListItem.cs b/samples/AIExtensions.Sample.Garden/Models/ListItem.cs new file mode 100644 index 000000000..e72839aa3 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/Models/ListItem.cs @@ -0,0 +1,11 @@ +namespace AIExtensions.Sample.Garden.Models; + +/// +/// A line item in a cart or order. +/// +public record ListItem( + Product Product, + int Quantity) +{ + public decimal Subtotal => Product.Price * Quantity; +} diff --git a/samples/AIExtensions.Sample.Garden/Models/Order.cs b/samples/AIExtensions.Sample.Garden/Models/Order.cs new file mode 100644 index 000000000..c5078e7f4 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/Models/Order.cs @@ -0,0 +1,13 @@ +namespace AIExtensions.Sample.Garden.Models; + +/// +/// A committed shopping order in the singleton archive. +/// Created by checkout_list after the user approves the transition. +/// +public record Order( + string Id, + DateTime PlacedAt, + IReadOnlyList Items) +{ + public decimal Total => Items.Sum(i => i.Subtotal); +} diff --git a/samples/AIExtensions.Sample.Garden/Models/Product.cs b/samples/AIExtensions.Sample.Garden/Models/Product.cs new file mode 100644 index 000000000..e68cd1260 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/Models/Product.cs @@ -0,0 +1,16 @@ +namespace AIExtensions.Sample.Garden.Models; + +/// +/// A product in the garden shop catalog (seeds, soil, tools, fertilizer, etc.). +/// +/// Stable id used by tools. +/// Display name shown in chat and on cards. +/// Top-level grouping for the workspace panel. +/// Unit price in USD. +/// Emoji shown next to the product everywhere. +public record Product( + string Sku, + string Name, + string Category, + decimal Price, + string Emoji); diff --git a/samples/AIExtensions.Sample.Garden/Models/ProductReview.cs b/samples/AIExtensions.Sample.Garden/Models/ProductReview.cs new file mode 100644 index 000000000..623caa575 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/Models/ProductReview.cs @@ -0,0 +1,10 @@ +namespace AIExtensions.Sample.Garden.Models; + +/// +/// A user review on a product. +/// +public record ProductReview( + string ProductSku, + int Rating, + string? Comment, + DateTime CreatedAt); diff --git a/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml b/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml new file mode 100644 index 000000000..9716cf400 --- /dev/null +++ b/samples/AIExtensions.Sample.Garden/Pages/CartPage.xaml @@ -0,0 +1,65 @@ + + + + + + + + +