diff --git a/.github/workflows/ci-aicontrols.yml b/.github/workflows/ci-aicontrols.yml new file mode 100644 index 000000000..838029097 --- /dev/null +++ b/.github/workflows/ci-aicontrols.yml @@ -0,0 +1,40 @@ +name: CI - AI Controls + +on: + push: + branches: [main] + paths: + - 'src/AIControls/**' + - 'tests/Microsoft.AspNetCore.Components.AI.*/**' + - 'tests/Microsoft.Maui.AI.Chat.Controls.Tests/**' + - 'samples/AiControls*/**' + - 'eng/**' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'global.json' + - 'NuGet.config' + pull_request: + types: [opened, synchronize, reopened, edited] + branches: [main] + paths: + - 'src/AIControls/**' + - 'tests/Microsoft.AspNetCore.Components.AI.*/**' + - 'tests/Microsoft.Maui.AI.Chat.Controls.Tests/**' + - 'samples/AiControls*/**' + - '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/AIControls/AIControls.slnx + project-name: aicontrols + run-tests: true + pack: true + install-workloads: true diff --git a/Directory.Packages.props b/Directory.Packages.props index bdecf324e..8461fa0c1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -34,6 +34,7 @@ + @@ -98,6 +99,8 @@ + + diff --git a/MauiLabs.slnx b/MauiLabs.slnx index a4ceebe82..c0c900402 100644 --- a/MauiLabs.slnx +++ b/MauiLabs.slnx @@ -8,6 +8,7 @@ + @@ -20,6 +21,11 @@ + + + + + @@ -58,6 +64,7 @@ + diff --git a/eng/Versions.props b/eng/Versions.props index 5dc60f563..757d07d77 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -40,6 +40,7 @@ 10.4.1 10.4.1 2.1.0 + 1.13.2 3.11.0 diff --git a/samples/AiControlsBlazorSample/AiControlsBlazorSample.csproj b/samples/AiControlsBlazorSample/AiControlsBlazorSample.csproj new file mode 100644 index 000000000..7ab418442 --- /dev/null +++ b/samples/AiControlsBlazorSample/AiControlsBlazorSample.csproj @@ -0,0 +1,74 @@ + + + + net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + Exe + AiControlsBlazorSample + true + true + enable + enable + true + $(NoWarn);XC0022;MEAI001;NU1510 + false + + ai-attributes-secrets + + AI Controls Blazor Sample + com.microsoft.maui.aicontrols.blazorsample + 1.0 + 1 + + 15.0 + 15.0 + 24.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([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/AiControlsBlazorSample/App.xaml b/samples/AiControlsBlazorSample/App.xaml new file mode 100644 index 000000000..47a96f3ed --- /dev/null +++ b/samples/AiControlsBlazorSample/App.xaml @@ -0,0 +1,5 @@ + + + diff --git a/samples/AiControlsBlazorSample/App.xaml.cs b/samples/AiControlsBlazorSample/App.xaml.cs new file mode 100644 index 000000000..04184f039 --- /dev/null +++ b/samples/AiControlsBlazorSample/App.xaml.cs @@ -0,0 +1,14 @@ +namespace AiControlsBlazorSample; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new MainPage()) { Title = "AI Controls Blazor Sample" }; + } +} diff --git a/samples/AiControlsBlazorSample/Components/MainLayout.razor b/samples/AiControlsBlazorSample/Components/MainLayout.razor new file mode 100644 index 000000000..5903a012e --- /dev/null +++ b/samples/AiControlsBlazorSample/Components/MainLayout.razor @@ -0,0 +1,5 @@ +@inherits LayoutComponentBase + +
+ @Body +
diff --git a/samples/AiControlsBlazorSample/Components/Pages/ChatDemo.razor b/samples/AiControlsBlazorSample/Components/Pages/ChatDemo.razor new file mode 100644 index 000000000..6753b597c --- /dev/null +++ b/samples/AiControlsBlazorSample/Components/Pages/ChatDemo.razor @@ -0,0 +1,32 @@ +@page "/" +@using Microsoft.AspNetCore.Components.AI +@using Microsoft.Extensions.AI +@inject IChatClient ChatClient + + + +@code { + private UIAgent _agent = default!; + + protected override void OnInitialized() + { + var tools = new List + { + AIFunctionFactory.Create(GetWeather, "GetCurrentWeather", "Get the current weather for a location") + }; + + var chatOptions = new ChatOptions + { + Tools = tools + }; + + _agent = new UIAgent(ChatClient, chatOptions); + } + + [System.ComponentModel.Description("Get current weather for a city")] + private static string GetWeather( + [System.ComponentModel.Description("City name")] string city) + { + return $"The weather in {city} is sunny, 72Β°F with light clouds."; + } +} diff --git a/samples/AiControlsBlazorSample/Components/Routes.razor b/samples/AiControlsBlazorSample/Components/Routes.razor new file mode 100644 index 000000000..8d733b577 --- /dev/null +++ b/samples/AiControlsBlazorSample/Components/Routes.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components.AI + + + + + + diff --git a/samples/AiControlsBlazorSample/MainPage.xaml b/samples/AiControlsBlazorSample/MainPage.xaml new file mode 100644 index 000000000..ac4e392f8 --- /dev/null +++ b/samples/AiControlsBlazorSample/MainPage.xaml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/samples/AiControlsBlazorSample/MainPage.xaml.cs b/samples/AiControlsBlazorSample/MainPage.xaml.cs new file mode 100644 index 000000000..47d619346 --- /dev/null +++ b/samples/AiControlsBlazorSample/MainPage.xaml.cs @@ -0,0 +1,9 @@ +namespace AiControlsBlazorSample; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + InitializeComponent(); + } +} diff --git a/samples/AiControlsBlazorSample/MauiProgram.cs b/samples/AiControlsBlazorSample/MauiProgram.cs new file mode 100644 index 000000000..3921e4986 --- /dev/null +++ b/samples/AiControlsBlazorSample/MauiProgram.cs @@ -0,0 +1,119 @@ +using System.ClientModel; +using System.Reflection; +using Azure.AI.OpenAI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AiControlsBlazorSample; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder + .UseMauiApp() + .ConfigureFonts(fonts => + { + fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"); + }); + + builder.Services.AddMauiBlazorWebView(); + +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + + builder.Configuration.AddUserSecrets(); + + // Register Azure OpenAI as IChatClient with function invocation middleware + builder.AddOpenAIServices(); + + 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)) + { + // Register a no-op client so the app compiles and launches without secrets + builder.Services.AddSingleton(new NoOpChatClient()); + return builder; + } + + var azureClient = new AzureOpenAIClient( + new Uri(endpoint), + new ApiKeyCredential(apiKey)); + var chatClient = azureClient.GetChatClient(deploymentName); + + builder.Services.AddSingleton(sp => + { + var lf = sp.GetRequiredService(); + return chatClient.AsIChatClient() + .AsBuilder() + .UseLogging(lf) + .UseFunctionInvocation() + .Build(sp); + }); + + return builder; + } +} + +/// +/// Fallback chat client used when AI services are not configured. +/// +internal sealed class NoOpChatClient : IChatClient +{ + public ChatClientMetadata Metadata { get; } = new("NoOp"); + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + var response = new ChatResponse(new ChatMessage(ChatRole.Assistant, + "AI services are not configured. Please set up user secrets.")); + return Task.FromResult(response); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + return GetStreamingResponseCore(cancellationToken); + } + + private static async IAsyncEnumerable GetStreamingResponseCore( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.CompletedTask; + yield return new ChatResponseUpdate(ChatRole.Assistant, + "AI services are not configured. Please set up user secrets."); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() { } +} diff --git a/samples/AiControlsBlazorSample/Platforms/Android/AndroidManifest.xml b/samples/AiControlsBlazorSample/Platforms/Android/AndroidManifest.xml new file mode 100644 index 000000000..e56b911ae --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/AiControlsBlazorSample/Platforms/Android/MainActivity.cs b/samples/AiControlsBlazorSample/Platforms/Android/MainActivity.cs new file mode 100644 index 000000000..7f19b8dcb --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/Android/MainActivity.cs @@ -0,0 +1,9 @@ +using Android.App; +using Android.Content.PM; + +namespace AiControlsBlazorSample; + +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity +{ +} diff --git a/samples/AiControlsBlazorSample/Platforms/Android/MainApplication.cs b/samples/AiControlsBlazorSample/Platforms/Android/MainApplication.cs new file mode 100644 index 000000000..dd18be1d7 --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/Android/MainApplication.cs @@ -0,0 +1,15 @@ +using Android.App; +using Android.Runtime; + +namespace AiControlsBlazorSample; + +[Application] +public class MainApplication : MauiApplication +{ + public MainApplication(IntPtr handle, JniHandleOwnership ownership) + : base(handle, ownership) + { + } + + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/AiControlsBlazorSample/Platforms/MacCatalyst/AppDelegate.cs b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 000000000..44c4c5c46 --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace AiControlsBlazorSample; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Entitlements.plist b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 000000000..18b015635 --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Info.plist b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Info.plist new file mode 100644 index 000000000..a4ac3db1e --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,16 @@ + + + + + UIDeviceFamily + + 2 + + UIRequiredDeviceCapabilities + + arm64 + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Program.cs b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Program.cs new file mode 100644 index 000000000..bb8eac96a --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,12 @@ +using ObjCRuntime; +using UIKit; + +namespace AiControlsBlazorSample; + +public class Program +{ + static void Main(string[] args) + { + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/samples/AiControlsBlazorSample/Platforms/iOS/AppDelegate.cs b/samples/AiControlsBlazorSample/Platforms/iOS/AppDelegate.cs new file mode 100644 index 000000000..44c4c5c46 --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace AiControlsBlazorSample; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/samples/AiControlsBlazorSample/Platforms/iOS/Info.plist b/samples/AiControlsBlazorSample/Platforms/iOS/Info.plist new file mode 100644 index 000000000..0004a4fde --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/iOS/Info.plist @@ -0,0 +1,32 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + XSAppIconAssets + Assets.xcassets/appicon.appiconset + + diff --git a/samples/AiControlsBlazorSample/Platforms/iOS/Program.cs b/samples/AiControlsBlazorSample/Platforms/iOS/Program.cs new file mode 100644 index 000000000..bb8eac96a --- /dev/null +++ b/samples/AiControlsBlazorSample/Platforms/iOS/Program.cs @@ -0,0 +1,12 @@ +using ObjCRuntime; +using UIKit; + +namespace AiControlsBlazorSample; + +public class Program +{ + static void Main(string[] args) + { + UIApplication.Main(args, null, typeof(AppDelegate)); + } +} diff --git a/samples/AiControlsBlazorSample/Resources/AppIcon/appicon.svg b/samples/AiControlsBlazorSample/Resources/AppIcon/appicon.svg new file mode 100644 index 000000000..5f04fcfca --- /dev/null +++ b/samples/AiControlsBlazorSample/Resources/AppIcon/appicon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/samples/AiControlsBlazorSample/Resources/AppIcon/appiconfg.svg b/samples/AiControlsBlazorSample/Resources/AppIcon/appiconfg.svg new file mode 100644 index 000000000..62d66d7a6 --- /dev/null +++ b/samples/AiControlsBlazorSample/Resources/AppIcon/appiconfg.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/samples/AiControlsBlazorSample/_Imports.razor b/samples/AiControlsBlazorSample/_Imports.razor new file mode 100644 index 000000000..339eb6b19 --- /dev/null +++ b/samples/AiControlsBlazorSample/_Imports.razor @@ -0,0 +1,3 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.AI diff --git a/samples/AiControlsBlazorSample/wwwroot/css/app.css b/samples/AiControlsBlazorSample/wwwroot/css/app.css new file mode 100644 index 000000000..d38cfd01b --- /dev/null +++ b/samples/AiControlsBlazorSample/wwwroot/css/app.css @@ -0,0 +1,34 @@ +html, body { + margin: 0; + padding: 0; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + background-color: #1e1e2e; + color: #cdd6f4; +} + +.page { + display: flex; + flex-direction: column; + height: 100vh; +} + +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; + color: #333; +} + +#blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; +} diff --git a/samples/AiControlsBlazorSample/wwwroot/index.html b/samples/AiControlsBlazorSample/wwwroot/index.html new file mode 100644 index 000000000..90fdf8ccb --- /dev/null +++ b/samples/AiControlsBlazorSample/wwwroot/index.html @@ -0,0 +1,19 @@ + + + + + + AI Controls Blazor Sample + + + + +
Loading...
+
+ An unhandled error has occurred. + Reload + πŸ—™ +
+ + + diff --git a/samples/AiControlsSample/AiControlsSample.csproj b/samples/AiControlsSample/AiControlsSample.csproj new file mode 100644 index 000000000..4c493a658 --- /dev/null +++ b/samples/AiControlsSample/AiControlsSample.csproj @@ -0,0 +1,80 @@ + + + + net10.0-android + $(TargetFrameworks);net10.0-ios;net10.0-maccatalyst + $(TargetFrameworks);net10.0-windows10.0.19041.0 + + Exe + AiControlsSample + true + true + enable + enable + SourceGen + false + + ai-attributes-secrets + + AI Controls Sample + com.microsoft.maui.aicontrols.sample + 1.0 + 1 + + None + + 15.0 + 15.0 + 24.0 + 10.0.17763.0 + 10.0.17763.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + $([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/AiControlsSample/App.xaml b/samples/AiControlsSample/App.xaml new file mode 100644 index 000000000..16e8fdd00 --- /dev/null +++ b/samples/AiControlsSample/App.xaml @@ -0,0 +1,14 @@ +ο»Ώ + + + + + + + + + + diff --git a/samples/AiControlsSample/App.xaml.cs b/samples/AiControlsSample/App.xaml.cs new file mode 100644 index 000000000..57a6811d5 --- /dev/null +++ b/samples/AiControlsSample/App.xaml.cs @@ -0,0 +1,14 @@ +namespace AiControlsSample; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + return new Window(new AppShell()) { Title = "MAUI AI Sample" }; + } +} \ No newline at end of file diff --git a/samples/AiControlsSample/AppShell.xaml b/samples/AiControlsSample/AppShell.xaml new file mode 100644 index 000000000..5fc8496ee --- /dev/null +++ b/samples/AiControlsSample/AppShell.xaml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/AiControlsSample/AppShell.xaml.cs b/samples/AiControlsSample/AppShell.xaml.cs new file mode 100644 index 000000000..856cf4d12 --- /dev/null +++ b/samples/AiControlsSample/AppShell.xaml.cs @@ -0,0 +1,9 @@ +ο»Ώnamespace AiControlsSample; + +public partial class AppShell : Shell +{ + public AppShell() + { + InitializeComponent(); + } +} diff --git a/samples/AiControlsSample/Demos/AgenticChat/AgenticChatPage.xaml b/samples/AiControlsSample/Demos/AgenticChat/AgenticChatPage.xaml new file mode 100644 index 000000000..9ab7193ee --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticChat/AgenticChatPage.xaml @@ -0,0 +1,30 @@ + + + + + + + Make the background a warm sunset orange + Something calming and peaceful + Surprise me with a bold color + + + + + + + + + + + + diff --git a/samples/AiControlsSample/Demos/AgenticChat/AgenticChatPage.xaml.cs b/samples/AiControlsSample/Demos/AgenticChat/AgenticChatPage.xaml.cs new file mode 100644 index 000000000..2df4184b8 --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticChat/AgenticChatPage.xaml.cs @@ -0,0 +1,54 @@ +using System.ComponentModel; +using Microsoft.AspNetCore.Components.AI; +using Microsoft.Extensions.AI; + +namespace AiControlsSample; + +public partial class AgenticChatPage : ContentPage +{ + public AgentContext Session { get; } + + public AgenticChatPage(IChatClient chatClient) + { + var tools = new List + { + AIFunctionFactory.Create( + [Description("Change the background color of the page. Use any valid color name or hex value like '#ADD8E6' or 'LightBlue'.")] + ( + [Description("Color to set, e.g. 'LightBlue', '#FFE0E0', 'Salmon', '#ADD8E6'")] string color + ) => + { + MainThread.BeginInvokeOnMainThread(() => + { + if (Color.TryParse(color, out var parsed)) + { + PageRoot.BackgroundColor = parsed; + } + else + { + if (Color.TryParse($"#{color}", out var hexParsed)) + PageRoot.BackgroundColor = hexParsed; + } + }); + return $"Background changed to {color}."; + }, + "change_background", + "Change the background color of the page.") + }; + + var chatOptions = new ChatOptions + { + Instructions = """ + You are a helpful assistant that can change the background color of the app. + When the user asks you to change the background, use the change_background tool. + Be creative with color suggestions if the user is vague. + After changing the background, briefly describe what you did. + """, + Tools = [.. tools] + }; + var agent = new UIAgent(chatClient, chatOptions); + Session = new AgentContext(agent); + + InitializeComponent(); + } +} diff --git a/samples/AiControlsSample/Demos/AgenticChat/README.md b/samples/AiControlsSample/Demos/AgenticChat/README.md new file mode 100644 index 000000000..0ca073636 --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticChat/README.md @@ -0,0 +1,35 @@ +# Agentic Chat + +## Overview + +Demonstrates an AI agent that can modify the app's UI in real time. The agent has a `change_background` tool that changes the page background color, showing how AI tool calls can produce immediate visual side effects. + +## Features Demonstrated + +- Custom `AgentContext` with inline tool definition via `AIFunctionFactory.Create` +- AI tool execution that directly mutates MAUI UI elements (`PageRoot.BackgroundColor`) +- `Color.TryParse()` accepting both named colors ("LightBlue") and hex values ("#ADD8E6") +- System prompt guiding the agent to be creative with color suggestions +- `MainThread.BeginInvokeOnMainThread` for thread-safe UI updates from tool callbacks + +## How to Use + +1. Navigate to **Agentic Chat** from the app shell +2. Type a message like "Make the background blue" or "Set it to a warm sunset orange" +3. Observe the page background change immediately after the agent invokes the tool +4. Try vague requests like "something calming" β€” the agent picks a creative color +5. Ask non-color questions to confirm normal conversational responses still work + +## Expected Behavior + +- The agent calls `change_background` with a color string whenever the user requests a color change +- The page `Grid` background transitions to the parsed color instantly +- If a color cannot be parsed directly, the code retries with a `#` prefix (e.g., "ADD8E6" β†’ "#ADD8E6") +- The assistant describes what it did after each tool invocation +- Non-color questions receive standard conversational replies without tool calls + +## Key Code Patterns + +- **Inline tool registration** β€” the tool lambda is defined directly in the constructor using `AIFunctionFactory.Create` with `[Description]` attributes on parameters (`AgenticChatPage.xaml.cs:13-37`) +- **Thread-safe UI mutation** β€” `MainThread.BeginInvokeOnMainThread(() => PageRoot.BackgroundColor = parsed)` ensures the color change runs on the UI thread (`AgenticChatPage.xaml.cs:21-31`) +- **Custom AgentContext** β€” the page creates its own `UIAgent` + `AgentContext` with a dedicated tool list rather than using a shared session (`AgenticChatPage.xaml.cs:39-50`) diff --git a/samples/AiControlsSample/Demos/AgenticChat/TESTING.md b/samples/AiControlsSample/Demos/AgenticChat/TESTING.md new file mode 100644 index 000000000..c792db3e7 --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticChat/TESTING.md @@ -0,0 +1,29 @@ +# Agentic Chat β€” Testing Guide + +This demo shows real-time UI mutation: the AI agent changes the page background color through a tool call. + +## Scenario 1: Change Background by Name + +1. Navigate to **Agentic Chat** from the flyout menu. +2. Type **"Make the background blue"** and send. +3. **Expected:** The agent calls `change_background`, the entire page background turns blue, and the assistant confirms: "I changed the background to blue" (or similar). + +## Scenario 2: Change Background by Hex Code + +1. Type **"Set the background to #FF6B6B"** and send. +2. **Expected:** The background changes to a coral/salmon color. The tool call badge shows the hex value passed. + +## Scenario 3: Vague Color Request + +1. Type **"Make it feel like a sunset"** and send. +2. **Expected:** The agent picks a creative color (orange, pink, gold, etc.) and applies it. The assistant describes which color it chose and why. + +## Scenario 4: Non-Color Conversation + +1. Type **"What's your favorite programming language?"** and send. +2. **Expected:** The agent responds conversationally WITHOUT calling the `change_background` tool. The background stays unchanged from the last color. + +## Scenario 5: Multiple Color Changes + +1. Send three color change requests in sequence: "Red", "Green", "Purple". +2. **Expected:** Each time the background updates immediately. The conversation shows three user messages, three tool calls, and three assistant responses. The final background is purple. diff --git a/samples/AiControlsSample/Demos/AgenticGenerativeUI/AgenticGenerativeUIPage.xaml b/samples/AiControlsSample/Demos/AgenticGenerativeUI/AgenticGenerativeUIPage.xaml new file mode 100644 index 000000000..8ec932fdc --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticGenerativeUI/AgenticGenerativeUIPage.xaml @@ -0,0 +1,43 @@ + + + + + + + + Build a simple landing page + Create a 3-step onboarding flow + Design a color palette for a tech startup + + + + + + + + + + + + + + + + + diff --git a/samples/AiControlsSample/Demos/AgenticGenerativeUI/AgenticGenerativeUIPage.xaml.cs b/samples/AiControlsSample/Demos/AgenticGenerativeUI/AgenticGenerativeUIPage.xaml.cs new file mode 100644 index 000000000..ebf7c6be3 --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticGenerativeUI/AgenticGenerativeUIPage.xaml.cs @@ -0,0 +1,100 @@ +using System.ComponentModel; +using System.Text.Json; +using Microsoft.AspNetCore.Components.AI; +using Microsoft.Extensions.AI; + +namespace AiControlsSample; + +public partial class AgenticGenerativeUIPage : ContentPage +{ + public AgentContext Session { get; } + + private List? _currentSteps; + + public AgenticGenerativeUIPage(IChatClient chatClient) + { + var tools = new List + { + AIFunctionFactory.Create(CreatePlan, "create_plan", "Create a plan with the given steps. Returns the plan ID."), + AIFunctionFactory.Create(CompleteStep, "complete_step", "Mark a plan step as completed by zero-based index.") + }; + + var chatOptions = new ChatOptions + { + Instructions = """ + You are an auto-planner. When the user asks you to do something: + 1. Create a plan by calling create_plan with step descriptions. + 2. Immediately start executing each step, calling complete_step for each. + 3. Do NOT wait for user confirmation β€” just execute. + 4. After all steps are done, summarize what you accomplished. + + Create 3-5 concrete steps. Execute them one by one. + """, + Tools = [.. tools] + }; + var agent = new UIAgent(chatClient, chatOptions); + Session = new AgentContext(agent); + + InitializeComponent(); + } + + [Description("Create a plan with the given steps. Returns the plan ID.")] + private string CreatePlan( + [Description("JSON array of step descriptions")] string steps_json) + { + var steps = JsonSerializer.Deserialize>(steps_json) ?? []; + _currentSteps = steps.Select(s => new PlanStep { Description = s }).ToList(); + + MainThread.BeginInvokeOnMainThread(() => + { + PlanFooter.IsVisible = true; + RefreshStepsUI(); + }); + + return $"Plan created with {_currentSteps.Count} steps."; + } + + [Description("Mark a plan step as completed by zero-based index.")] + private string CompleteStep( + [Description("Zero-based step index to complete")] int step_index) + { + MainThread.BeginInvokeOnMainThread(() => + { + if (_currentSteps is not null && step_index >= 0 && step_index < _currentSteps.Count) + { + _currentSteps[step_index].IsCompleted = true; + RefreshStepsUI(); + } + }); + return $"Step {step_index} completed."; + } + + private void RefreshStepsUI() + { + StepsLayout.Children.Clear(); + if (_currentSteps is null) + return; + + for (int i = 0; i < _currentSteps.Count; i++) + { + var step = _currentSteps[i]; + var icon = step.IsCompleted ? "βœ…" : "⏳"; + var row = new HorizontalStackLayout { Spacing = 8 }; + row.Children.Add(new Label { Text = icon, FontSize = 14, VerticalOptions = LayoutOptions.Center }); + row.Children.Add(new Label + { + Text = step.Description, + FontSize = 13, + VerticalOptions = LayoutOptions.Center, + Opacity = step.IsCompleted ? 0.6 : 1.0 + }); + StepsLayout.Children.Add(row); + } + } + + private sealed class PlanStep + { + public string Description { get; init; } = string.Empty; + public bool IsCompleted { get; set; } + } +} diff --git a/samples/AiControlsSample/Demos/AgenticGenerativeUI/README.md b/samples/AiControlsSample/Demos/AgenticGenerativeUI/README.md new file mode 100644 index 000000000..b1bbe43b4 --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticGenerativeUI/README.md @@ -0,0 +1,36 @@ +# Agentic Generative UI + +## Overview + +Demonstrates auto-executing plan generation with real-time progress tracking. Unlike the Human-in-the-Loop demo, this agent executes its plan immediately without waiting for user confirmation. + +## Features Demonstrated + +- `create_plan` tool that deserializes step descriptions and shows a progress footer +- `complete_step` tool that marks steps as completed by zero-based index +- Automatic execution β€” the system prompt instructs the agent to proceed without waiting +- Footer-based progress UI with ⏳ (pending) and βœ… (completed) status indicators +- No confirm/reject buttons β€” fully autonomous agent behavior + +## How to Use + +1. Navigate to **Agentic Generative UI** from the app shell +2. Ask the agent to do something multi-step, e.g., "Build a plan to make pizza from scratch" +3. Watch the footer appear showing all plan steps as ⏳ pending +4. Observe each step transition to βœ… completed as the agent executes automatically +5. Read the agent's summary message after all steps are done +6. Try another request β€” a new plan replaces the previous one + +## Expected Behavior + +- The agent calls `create_plan` with a JSON array of 3-5 step descriptions β†’ the plan footer becomes visible +- Without pausing, the agent immediately calls `complete_step(0)`, `complete_step(1)`, etc., in sequence +- Each step transitions from ⏳ to βœ… with reduced opacity for completed items +- After all steps complete, the agent sends a conversational summary of what was accomplished +- A new request creates a fresh plan, replacing the previous steps in the footer + +## Key Code Patterns + +- **No confirmation gate** β€” compared to HumanInTheLoop, this demo has no confirm/reject mechanism; the system prompt says "Do NOT wait for user confirmation β€” just execute" (`AgenticGenerativeUIPage.xaml.cs:24-32`) +- **Footer layout** β€” the plan progress is displayed in a `Border` at `Grid.Row="1"` below the chat rather than a side panel (`AgenticGenerativeUIPage.xaml:25-35`) +- **Shared step rendering** β€” `RefreshStepsUI()` pattern is identical to HumanInTheLoop but uses ⏳/βœ… instead of ⬜/βœ… (`AgenticGenerativeUIPage.xaml.cs:69-90`) diff --git a/samples/AiControlsSample/Demos/AgenticGenerativeUI/TESTING.md b/samples/AiControlsSample/Demos/AgenticGenerativeUI/TESTING.md new file mode 100644 index 000000000..cb9296791 --- /dev/null +++ b/samples/AiControlsSample/Demos/AgenticGenerativeUI/TESTING.md @@ -0,0 +1,25 @@ +# Agentic Generative UI β€” Testing Guide + +This demo shows fully autonomous plan execution: the agent creates a plan and immediately executes all steps without waiting for user approval. + +## Scenario 1: Auto-Executing Plan + +1. Navigate to **Agentic Gen UI** from the flyout menu. +2. Type **"Create a plan to learn photography"** and send. +3. **Expected:** A footer panel appears at the bottom showing "πŸš€ Plan Progress" with 3–5 steps. The agent immediately starts executing β€” each step transitions from ⏳ to βœ… without any user interaction. The agent sends a summary when done. + +## Scenario 2: Watch Step Progression + +1. Type **"Plan to bake a cake from scratch"** and send. +2. Watch the footer progress. +3. **Expected:** Steps complete one at a time (not all at once). You can see the ⏳ β†’ βœ… transition happen for each step in sequence as the agent processes. + +## Scenario 3: New Plan Replaces Old + +1. After the first plan completes, type **"Now plan a camping trip"** and send. +2. **Expected:** The old plan steps in the footer are replaced with new camping-related steps. The new steps start executing immediately. + +## Scenario 4: Compare with Human in the Loop + +1. Note: Unlike the HITL demo, there are **no Confirm/Reject buttons**. The agent just goes. +2. **Expected:** This is intentional β€” the Agentic Generative UI demo demonstrates fully autonomous execution versus the supervised approach in Human in the Loop. diff --git a/samples/AiControlsSample/Demos/HaikuGenerator/HaikuPage.xaml b/samples/AiControlsSample/Demos/HaikuGenerator/HaikuPage.xaml new file mode 100644 index 000000000..3efb61a5a --- /dev/null +++ b/samples/AiControlsSample/Demos/HaikuGenerator/HaikuPage.xaml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + +