diff --git a/Directory.Build.props b/Directory.Build.props
index 57f06a40f636..55f8a7d31e96 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -26,6 +26,7 @@
false
false
false
+ false
@@ -256,6 +257,13 @@
$(MauiPreviousPlatforms)
$(MauiGraphicsPreviousPlatforms);net$(_MauiPreviousDotNetVersion)-macos$(MacosPreviousTargetFrameworkVersion)
+
+
+ $(MauiPlatforms)
+ $(MauiEssentialsAIPlatforms);net$(_MauiDotNetVersion)-macos$(MacosTargetFrameworkVersion)
+
+ $(MauiPreviousPlatforms)
+ $(MauiEssentialsAIPreviousPlatforms);net$(_MauiPreviousDotNetVersion)-macos$(MacosPreviousTargetFrameworkVersion)
diff --git a/Microsoft.Maui-dev.sln b/Microsoft.Maui-dev.sln
index 794ff6270d15..9af223fe1b84 100644
--- a/Microsoft.Maui-dev.sln
+++ b/Microsoft.Maui-dev.sln
@@ -246,6 +246,24 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Maui.Controls.Sample.Embedd
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.ManualTests", "src\Controls\tests\ManualTests\Controls.ManualTests.csproj", "{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AI", "AI", "{BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9B194A14-0963-842E-BCDD-4A2CBB559451}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{81F26DD8-70CC-7F24-050C-594BCCA2AEBB}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BE0B3DF2-F37C-449D-F624-39E4FE56170C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI", "src\AI\src\Essentials.AI\Essentials.AI.csproj", "{376DAEDF-D41D-4AF4-9548-9DDC9538D533}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.Sample", "src\AI\samples\Essentials.AI.Sample\Essentials.AI.Sample.csproj", "{7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.Benchmarks", "src\AI\tests\Essentials.AI.Benchmarks\Essentials.AI.Benchmarks.csproj", "{B578E852-193C-4F37-ACB1-2B9B1B32F6B6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.DeviceTests", "src\AI\tests\Essentials.AI.DeviceTests\Essentials.AI.DeviceTests.csproj", "{90B0A751-82EA-4575-B22C-256EF3A4FEF1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.UnitTests", "src\AI\tests\Essentials.AI.UnitTests\Essentials.AI.UnitTests.csproj", "{BD5D52FA-CA8E-4236-979A-95B4CE4158D8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -635,6 +653,26 @@ Global
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -748,6 +786,14 @@ Global
{55905937-1399-46DB-BA38-E426801CB759} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C}
{4ADCBA87-30DB-44F5-85E9-94A4F4132FD9} = {E1082E26-D700-4127-9329-66D673FD2D55}
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
+ {9B194A14-0963-842E-BCDD-4A2CBB559451} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {81F26DD8-70CC-7F24-050C-594BCCA2AEBB} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {BE0B3DF2-F37C-449D-F624-39E4FE56170C} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533} = {9B194A14-0963-842E-BCDD-4A2CBB559451}
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0} = {81F26DD8-70CC-7F24-050C-594BCCA2AEBB}
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B8ABEAD-D2B5-4370-A187-62B5ABE4EE50}
diff --git a/Microsoft.Maui-mac.slnf b/Microsoft.Maui-mac.slnf
index d459143e2a5a..623e1571661a 100644
--- a/Microsoft.Maui-mac.slnf
+++ b/Microsoft.Maui-mac.slnf
@@ -2,6 +2,11 @@
"solution": {
"path": "Microsoft.Maui-dev.sln",
"projects": [
+ "src\\AI\\samples\\Essentials.AI.Sample\\Essentials.AI.Sample.csproj",
+ "src\\AI\\src\\Essentials.AI\\Essentials.AI.csproj",
+ "src\\AI\\tests\\Essentials.AI.Benchmarks\\Essentials.AI.Benchmarks.csproj",
+ "src\\AI\\tests\\Essentials.AI.DeviceTests\\Essentials.AI.DeviceTests.csproj",
+ "src\\AI\\tests\\Essentials.AI.UnitTests\\Essentials.AI.UnitTests.csproj",
"src\\BlazorWebView\\samples\\MauiRazorClassLibrarySample\\MauiRazorClassLibrarySample.csproj",
"src\\BlazorWebView\\samples\\WebViewAppShared\\WebViewAppShared.csproj",
"src\\BlazorWebView\\src\\Maui\\Microsoft.AspNetCore.Components.WebView.Maui.csproj",
diff --git a/Microsoft.Maui-vscode.sln b/Microsoft.Maui-vscode.sln
index 7217d8c92cfd..3e88b3db593e 100644
--- a/Microsoft.Maui-vscode.sln
+++ b/Microsoft.Maui-vscode.sln
@@ -220,6 +220,24 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SourceGen.UnitTests", "src\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Maui.Controls.Xaml.Benchmarks", "src\Controls\tests\Xaml.Benchmarks\Microsoft.Maui.Controls.Xaml.Benchmarks.csproj", "{9A0A5037-DB03-4C80-876C-61FCDAE4CCAD}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AI", "AI", "{BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9B194A14-0963-842E-BCDD-4A2CBB559451}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{81F26DD8-70CC-7F24-050C-594BCCA2AEBB}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BE0B3DF2-F37C-449D-F624-39E4FE56170C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI", "src\AI\src\Essentials.AI\Essentials.AI.csproj", "{376DAEDF-D41D-4AF4-9548-9DDC9538D533}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.Sample", "src\AI\samples\Essentials.AI.Sample\Essentials.AI.Sample.csproj", "{7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.Benchmarks", "src\AI\tests\Essentials.AI.Benchmarks\Essentials.AI.Benchmarks.csproj", "{B578E852-193C-4F37-ACB1-2B9B1B32F6B6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.DeviceTests", "src\AI\tests\Essentials.AI.DeviceTests\Essentials.AI.DeviceTests.csproj", "{90B0A751-82EA-4575-B22C-256EF3A4FEF1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.UnitTests", "src\AI\tests\Essentials.AI.UnitTests\Essentials.AI.UnitTests.csproj", "{BD5D52FA-CA8E-4236-979A-95B4CE4158D8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -563,6 +581,26 @@ Global
{9A0A5037-DB03-4C80-876C-61FCDAE4CCAD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9A0A5037-DB03-4C80-876C-61FCDAE4CCAD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9A0A5037-DB03-4C80-876C-61FCDAE4CCAD}.Release|Any CPU.Build.0 = Release|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -664,6 +702,14 @@ Global
{4ADCBA87-30DB-44F5-85E9-94A4F4132FD9} = {E1082E26-D700-4127-9329-66D673FD2D55}
{A426B2FC-F012-436B-BDD9-BEC0025DB96B} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
{9A0A5037-DB03-4C80-876C-61FCDAE4CCAD} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
+ {9B194A14-0963-842E-BCDD-4A2CBB559451} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {81F26DD8-70CC-7F24-050C-594BCCA2AEBB} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {BE0B3DF2-F37C-449D-F624-39E4FE56170C} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533} = {9B194A14-0963-842E-BCDD-4A2CBB559451}
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0} = {81F26DD8-70CC-7F24-050C-594BCCA2AEBB}
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B8ABEAD-D2B5-4370-A187-62B5ABE4EE50}
diff --git a/Microsoft.Maui-windows.slnf b/Microsoft.Maui-windows.slnf
index 708d9629a8d9..4ed4c36e2225 100644
--- a/Microsoft.Maui-windows.slnf
+++ b/Microsoft.Maui-windows.slnf
@@ -2,6 +2,11 @@
"solution": {
"path": "Microsoft.Maui-dev.sln",
"projects": [
+ "src\\AI\\samples\\Essentials.AI.Sample\\Essentials.AI.Sample.csproj",
+ "src\\AI\\src\\Essentials.AI\\Essentials.AI.csproj",
+ "src\\AI\\tests\\Essentials.AI.Benchmarks\\Essentials.AI.Benchmarks.csproj",
+ "src\\AI\\tests\\Essentials.AI.DeviceTests\\Essentials.AI.DeviceTests.csproj",
+ "src\\AI\\tests\\Essentials.AI.UnitTests\\Essentials.AI.UnitTests.csproj",
"src\\BlazorWebView\\samples\\BlazorWinFormsApp\\BlazorWinFormsApp.csproj",
"src\\BlazorWebView\\samples\\BlazorWpfApp\\BlazorWpfApp.csproj",
"src\\BlazorWebView\\samples\\MauiRazorClassLibrarySample\\MauiRazorClassLibrarySample.csproj",
diff --git a/Microsoft.Maui.sln b/Microsoft.Maui.sln
index fbbeaadc0200..37c133515923 100644
--- a/Microsoft.Maui.sln
+++ b/Microsoft.Maui.sln
@@ -251,6 +251,24 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Maui.Controls.Sample.Embedd
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Controls.ManualTests", "src\Controls\tests\ManualTests\Controls.ManualTests.csproj", "{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AI", "AI", "{BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9B194A14-0963-842E-BCDD-4A2CBB559451}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{81F26DD8-70CC-7F24-050C-594BCCA2AEBB}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{BE0B3DF2-F37C-449D-F624-39E4FE56170C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI", "src\AI\src\Essentials.AI\Essentials.AI.csproj", "{376DAEDF-D41D-4AF4-9548-9DDC9538D533}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.Sample", "src\AI\samples\Essentials.AI.Sample\Essentials.AI.Sample.csproj", "{7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.Benchmarks", "src\AI\tests\Essentials.AI.Benchmarks\Essentials.AI.Benchmarks.csproj", "{B578E852-193C-4F37-ACB1-2B9B1B32F6B6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.DeviceTests", "src\AI\tests\Essentials.AI.DeviceTests\Essentials.AI.DeviceTests.csproj", "{90B0A751-82EA-4575-B22C-256EF3A4FEF1}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Essentials.AI.UnitTests", "src\AI\tests\Essentials.AI.UnitTests\Essentials.AI.UnitTests.csproj", "{BD5D52FA-CA8E-4236-979A-95B4CE4158D8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -648,6 +666,26 @@ Global
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -764,6 +802,14 @@ Global
{F1BC506B-3A9E-4779-994E-339AFB21C9B9} = {7AC28763-9C68-4BF9-A1BA-25CBFFD2D15C}
{4ADCBA87-30DB-44F5-85E9-94A4F4132FD9} = {E1082E26-D700-4127-9329-66D673FD2D55}
{E2BFD1F1-07A8-8DBE-3661-894D0FE37D9C} = {25D0D27A-C5FE-443D-8B65-D6C987F4A80E}
+ {9B194A14-0963-842E-BCDD-4A2CBB559451} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {81F26DD8-70CC-7F24-050C-594BCCA2AEBB} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {BE0B3DF2-F37C-449D-F624-39E4FE56170C} = {BA58FF10-E1F2-1F22-EED5-4647CFF8BF60}
+ {376DAEDF-D41D-4AF4-9548-9DDC9538D533} = {9B194A14-0963-842E-BCDD-4A2CBB559451}
+ {7A6CB0C2-B5A9-44BA-B774-D1E1C1E371D0} = {81F26DD8-70CC-7F24-050C-594BCCA2AEBB}
+ {B578E852-193C-4F37-ACB1-2B9B1B32F6B6} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
+ {90B0A751-82EA-4575-B22C-256EF3A4FEF1} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
+ {BD5D52FA-CA8E-4236-979A-95B4CE4158D8} = {BE0B3DF2-F37C-449D-F624-39E4FE56170C}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {0B8ABEAD-D2B5-4370-A187-62B5ABE4EE50}
diff --git a/eng/Microsoft.Maui.Packages-mac.slnf b/eng/Microsoft.Maui.Packages-mac.slnf
index 5034642aaa1c..5529edb22fdb 100644
--- a/eng/Microsoft.Maui.Packages-mac.slnf
+++ b/eng/Microsoft.Maui.Packages-mac.slnf
@@ -2,6 +2,7 @@
"solution": {
"path": "..\\Microsoft.Maui.sln",
"projects": [
+ "src\\AI\\src\\Essentials.AI\\Essentials.AI.csproj",
"src\\BlazorWebView\\src\\Maui\\Microsoft.AspNetCore.Components.WebView.Maui.csproj",
"src\\Compatibility\\Core\\src\\Compatibility.csproj",
"src\\Controls\\Foldable\\src\\Controls.Foldable.csproj",
diff --git a/eng/Microsoft.Maui.Packages.slnf b/eng/Microsoft.Maui.Packages.slnf
index 45bbff7c3a3a..7c21491d97e3 100644
--- a/eng/Microsoft.Maui.Packages.slnf
+++ b/eng/Microsoft.Maui.Packages.slnf
@@ -2,6 +2,7 @@
"solution": {
"path": "..\\Microsoft.Maui.sln",
"projects": [
+ "src\\AI\\src\\Essentials.AI\\Essentials.AI.csproj",
"src\\BlazorWebView\\src\\Maui\\Microsoft.AspNetCore.Components.WebView.Maui.csproj",
"src\\BlazorWebView\\src\\WindowsForms\\Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj",
"src\\BlazorWebView\\src\\Wpf\\Microsoft.AspNetCore.Components.WebView.Wpf.csproj",
diff --git a/eng/NuGetVersions.targets b/eng/NuGetVersions.targets
index 123b99d6c4ff..6063ac89b505 100644
--- a/eng/NuGetVersions.targets
+++ b/eng/NuGetVersions.targets
@@ -84,6 +84,14 @@
Update="System.Runtime.CompilerServices.Unsafe"
Version="$(SystemRuntimeCompilerServicesUnsafePackageVersion)"
/>
+
+
+
https://github.com/dotnet/templating
3f4da9ced34942d83054e647f3b1d9d7dde281e8
+
+ https://github.com/dotnet/dotnet
+ 7b29526f2107416f68578bcb9deaca74fcfcf7f0
+
+
+ https://github.com/dotnet/dotnet
+ 7b29526f2107416f68578bcb9deaca74fcfcf7f0
+
https://github.com/dotnet/dotnet
7b29526f2107416f68578bcb9deaca74fcfcf7f0
diff --git a/eng/Versions.props b/eng/Versions.props
index 5295426aefa5..a3c239e7d6b4 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -40,6 +40,8 @@
$(MicrosoftNETCoreAppRefPackageVersion)
$(MicrosoftNETCoreAppRefPackageVersion)
+ 10.0.0
+ 10.0.0
10.0.0
10.0.0
10.0.0
@@ -52,6 +54,7 @@
10.0.0
10.0.0
10.0.0
+ 10.0.0
36.1.2
35.0.105
diff --git a/src/AI/docs/json-stream-chunker-design.md b/src/AI/docs/json-stream-chunker-design.md
new file mode 100644
index 000000000000..3f553f9f3912
--- /dev/null
+++ b/src/AI/docs/json-stream-chunker-design.md
@@ -0,0 +1,1227 @@
+# JSON Stream Chunker - Complete Design Document
+
+## Problem Statement
+
+We have an AI model that receives progressive JSON internally but outputs complete, valid JSON objects each time. The property order may vary between chunks. We need to convert these complete JSON objects back into streaming chunks that, when concatenated, produce valid JSON matching the final output.
+
+**Input**: JSONL file where each line is a complete JSON object representing progressive construction
+**Output**: Chunks that when concatenated produce valid JSON structurally equivalent to the final line
+
+### Real-World Example (from serengeti-itinerary-1.jsonl)
+
+```
+Line 1: {"days": [{"subtitle": "Day"}]}
+Line 2: {"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": []}]}
+Line 3: {"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": [{"title": "", "type": "Sightseeing"}]}]}
+Line 4: {"days": [{"activities": [{"type": "Sightseeing", "description": "Embark", "title": "Morning Game Drive"}], "subtitle": "Day 1: Arrival and Wildlife Safari"}]}
+...
+```
+
+**Observations from real data:**
+1. Property order changes between chunks (subtitle moves around)
+2. Strings grow progressively ("Day" → "Day 1: Arrival and Wildlife Safari")
+3. Empty strings `""` appear and then grow ("title": "" → "title": "Morning Game Drive")
+4. Arrays grow (activities: [] → activities: [{...}])
+5. New properties appear (description appears in line 4)
+6. Only ONE string changes per chunk (confirmed by data analysis)
+
+## Key Constraints
+
+1. **Must stream output** - Cannot wait until the end to emit
+2. **1-2 chunk delay OK** - For disambiguation when multiple growable items appear
+3. **Property order varies** - Must track by path, not position
+4. **Objects only grow** - Properties never removed, values only get longer/deeper
+5. **Only one value changes per chunk** - Confirmed by analysis of all test data
+
+## API Design
+
+### Class Signature
+
+```csharp
+namespace Microsoft.Maui.Essentials.AI;
+
+///
+/// Converts complete JSON objects (from progressive AI output) back into streaming chunks.
+///
+public class JsonStreamChunker
+{
+ ///
+ /// Process one complete JSON object and return a streaming chunk.
+ /// May return empty string (data pending) or a chunk to emit.
+ ///
+ /// A complete, valid JSON object representing the current state.
+ /// A chunk to emit, or empty string if data is pending.
+ public string Process(string completeJson);
+
+ ///
+ /// Finalize processing and return any remaining output.
+ /// Call this after all input has been processed.
+ ///
+ /// Final chunk including closing brackets and any pending strings.
+ public string Flush();
+}
+```
+
+### Usage Pattern
+
+```csharp
+// Consuming an async stream from an AI model
+public async IAsyncEnumerable ConvertToChunks(IAsyncEnumerable completeJsonStream)
+{
+ var chunker = new JsonStreamChunker();
+
+ await foreach (var completeJson in completeJsonStream)
+ {
+ var chunk = chunker.Process(completeJson);
+ if (!string.IsNullOrEmpty(chunk))
+ {
+ yield return chunk;
+ }
+ }
+
+ var finalChunk = chunker.Flush();
+ if (!string.IsNullOrEmpty(finalChunk))
+ {
+ yield return finalChunk;
+ }
+}
+
+// Result: Concatenating all yielded chunks produces valid JSON
+// equivalent to the final input object
+```
+
+### Behavior Contract
+
+1. **Process()** returns a chunk that should be emitted immediately
+2. **Process()** may return empty string if data is ambiguous (pending resolution)
+3. **Flush()** must be called after all input to close any open structures
+4. Concatenating all non-empty chunks from all Process() + Flush() calls produces valid JSON
+5. The output JSON is structurally equivalent to the final input JSON (property order may differ)
+
+---
+
+## Data Analysis Findings
+
+Analysis of all 4 test JSONL files revealed:
+
+| Pattern | Frequency | Notes |
+|---------|-----------|-------|
+| String grows | ~40-50 per file | Most common change type |
+| New string appears | ~39 per file | Often 1 at a time |
+| 2 new growable items at once | 0-4 per file | Requires pending list |
+| Multiple values change | 0 | Never happens - confirms assumption |
+| Empty array `[]` | Occasional | Gets populated in later chunk |
+| Empty object `{}` | Occasional | Gets populated in later chunk |
+| Non-string primitives | 0 in test data | All values are strings in these examples |
+
+**Key insight**: The "only one value changes per chunk" assumption holds in all test data (where values include strings, arrays, and objects).
+
+---
+
+## Core Rules
+
+### Rule 1: Sibling Rule
+If a new property appears at the same level as the open string, the open string is complete.
+*The AI moved on horizontally.*
+
+**Example:**
+```
+Previous: {"name": "Mat"}
+Current: {"name": "Matthew", "age": 30}
+
+"age" is a NEW sibling at same level as "name"
+→ "name" is COMPLETE (emit extension "thew", then close)
+→ Then emit the new sibling
+```
+
+### Rule 2: Parent-Level Rule
+If new content appears at a higher level (e.g., new array item in parent), the current container and its open string are complete.
+*The AI moved on vertically.*
+
+**Example:**
+```
+Previous: {"days": [{"title": "Day 1"}]}
+Current: {"days": [{"title": "Day 1"}, {"title": "Day 2"}]}
+
+days[1] appeared at parent level (days array)
+→ days[0] and everything inside it is COMPLETE
+→ Close days[0], then emit new array item
+```
+
+### Rule 3: Unchanged Rule
+If the open string's value is unchanged from the previous chunk, it is complete.
+
+**Example:**
+```
+Previous: {"name": "Matthew"}
+Current: {"name": "Matthew", "age": 30}
+
+"name" value unchanged
+→ "name" is COMPLETE
+(Note: Sibling rule would also apply here)
+```
+
+### Rule 4: Pending Rule
+If 2+ new **growable** items appear at the same parent level in the same chunk, add them to pending and wait for the next chunk to see which one changes.
+
+**Growable types:**
+- Strings: Can grow (characters appended)
+- Arrays: Can grow (items added)
+- Objects: Can grow (properties added)
+
+Numbers, bools, and null are NOT growable - they are always complete.
+
+**Example 1 - Multiple strings:**
+```
+Previous: {"count": 5}
+Current: {"count": 5, "a": "Hello", "b": "World"}
+
+2 new strings appeared at root level (siblings) - which one will grow?
+→ Add BOTH to pending
+→ Emit nothing for strings yet
+→ Wait for next chunk to see which changes
+```
+
+**Example 2 - String and array at same level:**
+```
+Previous: {"days": [{}]}
+Current: {"days": [{"subtitle": "", "activities": []}]}
+
+1 new string (subtitle) + 1 new array (activities) at days[0] level
+Total: 2 growable items at same level - which one will grow?
+→ Add subtitle (string) to _pendingStrings
+→ Add activities (array) to _pendingContainers
+→ Emit NOTHING for either yet
+→ Wait for next chunk to see which changes:
+ - If subtitle value changes → subtitle is active, activities was complete
+ - If activities gets children → activities is active, subtitle was complete
+ - If both unchanged → both were complete
+```
+
+---
+
+## State Variables
+
+```
+_prevState: Dictionary?
+ - Flattened path→value dictionary from last chunk (null on first call)
+ - Used to detect what changed
+ - JsonValue is a record struct: (JsonValueKind Kind, string? StringValue, string? RawValue)
+ - Stores the Kind, StringValue (for strings), and RawValue (raw JSON for non-strings)
+ - IMPORTANT: Empty containers are stored as entries with JsonValueKind.Array or JsonValueKind.Object
+ (so we know they exist even with no children)
+
+_openStringPath: string?
+ - Path of the currently open string (no closing quote emitted)
+ - At most ONE string can be open at a time
+ - null if no string is currently open
+
+_pendingStrings: Dictionary
+ - Map of path → value for strings we haven't emitted yet
+ - Populated when:
+ - 2+ new growable items (strings, arrays, objects) appear at the SAME parent level
+ - OR we already have an open value and encounter a new growable item
+ - Resolved at start of next chunk by comparing values
+ - Note: pending items may or may not be siblings (different nesting levels also go to pending)
+
+_pendingContainers: Dictionary
+ - Map of path → isArray for containers we haven't emitted yet
+ - Populated when 2+ new growable items appear at the same parent level
+ - Resolved at start of next chunk by checking if container grew (got children)
+ - Detection: count paths starting with container path in prev vs curr
+
+_emittedStrings: Dictionary
+ - Map of path → emitted value for strings we HAVE emitted
+ - Used to calculate extension: extension = current[emitted.Length..]
+
+_openStructures: Stack<(string path, bool isArray)>
+ - Stack of currently open containers
+ - Used to properly close structures when moving to different parts of tree
+ - IMPORTANT: When emitting at a different level, close structures down to target path
+
+_emittedPaths: HashSet
+ - Tracks which paths have been emitted
+ - Used to know when to emit commas (if sibling already emitted, prepend comma)
+ - IMPORTANT: Do NOT skip processing of existing array items just because path is in _emittedPaths
+```
+
+### Comma Rules
+
+- **First property in an object**: No leading comma → `"prop":"value`
+- **Subsequent properties**: Leading comma → `,"prop":"value`
+- **First item in an array**: No leading comma → `{` or `"value`
+- **Subsequent items**: Leading comma → `,{` or `,"value`
+
+Check `_emittedPaths` to see if any sibling was already emitted at the same parent level.
+If yes, prepend comma. If no, don't.
+
+## Path Notation
+
+We flatten JSON into paths:
+- Object properties: `parent.child`
+- Array items: `parent[0]`, `parent[1]`
+- Nested: `days[0].activities[1].title`
+
+**Example:**
+```json
+{"days": [{"subtitle": "Day", "activities": [{"title": "Game"}]}]}
+```
+Flattens to:
+```
+days[0].subtitle = "Day"
+days[0].activities[0].title = "Game"
+```
+
+**Parent path calculation:**
+- `days[0].subtitle` → parent is `days[0]`
+- `days[0].activities[0].title` → parent is `days[0].activities[0]`
+- `days[0]` → parent is `days`
+
+---
+
+## Algorithm Overview
+
+```
+For each chunk:
+
+1. FIRST CHUNK (special path - no previous state):
+ - Parse and flatten JSON
+ - Emit root structure opening "{"
+ - Process all containers depth-first via EmitStructure()
+ - For each container, count its direct GROWABLE children (strings, arrays, objects):
+ - If 0 growable values at this level: emit all non-growables (numbers, bools, null), continue to nested containers
+ - If 1 growable value at this level:
+ - If string: emit property open (no closing quote), set _openStringPath
+ - If container (array/object): emit opening bracket, push to _openStructures, recurse into children
+ - If 2+ growable values at this level:
+ - Add strings to _pendingStrings
+ - Add containers to _pendingContainers
+ - Emit NOTHING for these until next chunk resolves which is active
+ - Result: at most 1 open string, rest in pending or fully emitted
+
+2. SUBSEQUENT CHUNKS (compare with previous state):
+ a. Step A - Handle open string (if _openStringPath is set):
+ - Check for new siblings at same level
+ - Check for new content at parent level
+ - If new sibling OR parent-level change:
+ → Emit extension (if value changed)
+ → Emit closing quote
+ → Set _openStringPath = null
+ - Else if value changed:
+ → Emit extension
+ → Keep open (might still grow)
+ - Else (value same):
+ → Emit closing quote
+ → Set _openStringPath = null
+
+ b. Step B - Resolve pending (if _pendingStrings or _pendingContainers not empty):
+ - For strings: Categorize as COMPLETE (unchanged) vs CHANGED
+ - For containers: Categorize as COMPLETE (no new children) vs CHANGED (got children)
+ - Detection: count paths with prefix in prev vs curr state
+ - Emit all COMPLETE items first (sorted by path for consistency):
+ - Strings: emit with closing quote
+ - Containers: emit opening AND closing brackets AND all content (they're complete)
+ - Emit the CHANGED one as open (if any):
+ - String: becomes _openStringPath
+ - Container: emit opening bracket, push to _openStructures, recurse into children
+ - After setting active string, check for new siblings → if found, close immediately
+
+ c. Step C - Process new content:
+ - For objects: iterate properties, categorize as existing vs new
+ - Existing non-strings: recurse into them
+ - New properties: categorize by growable type
+ - For arrays: iterate items, compare index to previous count
+ - Existing items: recurse into non-strings
+ - New items: emit via EmitNewArrayItem
+ - For new growable items:
+ - Count growables at same parent level
+ - If 1 growable AND no open value: emit open, set as active
+ - If 1 growable AND have open value: add to pending
+ - If 2+ growable: add ALL to pending, emit nothing
+
+3. FINALIZE (after last chunk):
+ - Emit any remaining pending items (all complete - sorted by path)
+ - Close _openStringPath if set (emit closing ")
+ - Close all open structures (emit } or ] for each, in reverse order from stack)
+```
+
+---
+
+## Detailed Step A: Handle Open String
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ STEP A: HANDLE OPEN STRING │
+└─────────────────────────────────────────────────────────────────────────────┘
+
+IF _openStringPath is set:
+ │
+ ├─► Get current value at _openStringPath
+ │ Get emitted value from _emittedStrings[_openStringPath]
+ │
+ ├─► Check for NEW siblings at same level
+ │ (properties in current that weren't in previous, at same parent path)
+ │
+ ├─► Check for NEW content at PARENT level
+ │ (e.g., new array item in parent array)
+ │
+ ├─► IF NEW SIBLING or PARENT-LEVEL CHANGE:
+ │ │
+ │ │ String is COMPLETE (AI moved on)
+ │ │
+ │ ├─► IF value changed:
+ │ │ extension = current[emitted.Length..]
+ │ │ Emit: extension + closing quote "
+ │ │
+ │ └─► ELSE:
+ │ Emit: closing quote "
+ │
+ │ Close any containers as needed:
+ │ Pop from _openStructures until we reach the level where new content will be emitted
+ │ Emit } or ] for each popped container
+ │ Set _openStringPath = null
+ │
+ └─► ELSE (no new siblings, no parent changes):
+ │
+ ├─► IF value CHANGED:
+ │ extension = current[emitted.Length..]
+ │ Emit: extension (no closing quote)
+ │ Update _emittedStrings[path] = current
+ │ Keep _openStringPath set (might still grow)
+ │
+ └─► ELSE (value SAME):
+ Emit: closing quote "
+ Set _openStringPath = null
+```
+
+**Real example - Sibling Rule (Line 2):**
+```
+Previous: {"days": [{"subtitle": "Day"}]}
+Current: {"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": []}]}
+
+_openStringPath = "days[0].subtitle"
+emitted = "Day"
+current = "Day 1: Arrival and Wildlife Safari"
+
+Check siblings at days[0]:
+ Previous had: subtitle
+ Current has: subtitle, activities
+ → "activities" is NEW SIBLING!
+
+Action:
+ extension = "Day 1: Arrival and Wildlife Safari"["Day".Length..] = " 1: Arrival and Wildlife Safari"
+ Emit: 1: Arrival and Wildlife Safari"
+ Set _openStringPath = null
+ Then emit new sibling: ,"activities":[
+
+Output: 1: Arrival and Wildlife Safari","activities":[
+```
+
+**Real example - Parent-Level Rule (Line 6):**
+```
+Previous: {"days": [{"activities": [{"description": "Embark on a thrilling..."}]}]}
+Current: {"days": [{"activities": [{"description": "...full text..."}, {"type": ""}]}]}
+
+_openStringPath = "days[0].activities[0].description"
+Parent of description = days[0].activities[0]
+Parent's parent = days[0].activities (array)
+
+Check: days[0].activities[1] is NEW
+→ New content at parent level!
+
+Action:
+ Emit extension for description
+ Close description: "
+ Close activities[0] object: }
+ Emit new array item: ,{
+ Handle strings in new item...
+```
+
+---
+
+## Detailed Step B: Resolve Pending
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ STEP B: RESOLVE PENDING │
+└─────────────────────────────────────────────────────────────────────────────┘
+
+IF _pendingStrings OR _pendingContainers is not empty:
+ │
+ ├─► RESOLVE PENDING STRINGS:
+ │ │
+ │ ├─► Categorize all pending strings:
+ │ │ For each (path, storedValue) in _pendingStrings:
+ │ │ currentValue = current[path]
+ │ │ IF currentValue == storedValue:
+ │ │ Add to COMPLETE_STRINGS list
+ │ │ ELSE:
+ │ │ Add to CHANGED_STRINGS list (should be exactly 0 or 1)
+ │ │
+ │ ├─► FIRST: Emit all COMPLETE strings (with closing quotes):
+ │ │ For each in COMPLETE_STRINGS:
+ │ │ needsComma = any sibling already in _emittedPaths
+ │ │ Emit: [,]"path":"value"
+ │ │ Add path to _emittedPaths
+ │ │
+ │ └─► Clear _pendingStrings
+ │
+ ├─► RESOLVE PENDING CONTAINERS:
+ │ │
+ │ ├─► Categorize all pending containers:
+ │ │ For each (path, isArray) in _pendingContainers:
+ │ │ previousChildCount = count of previous paths starting with this container
+ │ │ currentChildCount = count of current paths starting with this container
+ │ │ IF currentChildCount == previousChildCount:
+ │ │ Add to COMPLETE_CONTAINERS list (container didn't grow)
+ │ │ ELSE:
+ │ │ Add to CHANGED_CONTAINERS list (container got children)
+ │ │
+ │ ├─► Emit all COMPLETE containers (with opening AND closing brackets):
+ │ │ For each in COMPLETE_CONTAINERS:
+ │ │ needsComma = any sibling already in _emittedPaths
+ │ │ IF isArray: Emit: [,]"path":[]
+ │ │ ELSE: Emit: [,]"path":{}
+ │ │ Add path to _emittedPaths
+ │ │
+ │ └─► Clear _pendingContainers
+ │
+ ├─► EMIT THE CHANGED ITEM (at most 1 across strings and containers):
+ │ IF CHANGED_STRINGS has exactly 1:
+ │ needsComma = any sibling already in _emittedPaths
+ │ Emit: [,]"path":"value (no closing quote)
+ │ Set _openStringPath = path
+ │ Add path to _emittedPaths
+ │ Update _emittedStrings[path] = currentValue
+ │
+ │ IF CHANGED_CONTAINERS has exactly 1:
+ │ needsComma = any sibling already in _emittedPaths
+ │ IF isArray: Emit: [,]"path":[
+ │ ELSE: Emit: [,]"path":{
+ │ Push to _openStructures
+ │ Add path to _emittedPaths
+ │ → Recursively process children of this container
+ │
+ │ IF total CHANGED has 2+:
+ │ This should never happen per "only one value changes per chunk" invariant
+ │ Log warning and treat all as complete
+ │
+ └─► IF _openStringPath was just set:
+ Check for new siblings at same level (in current, not in previous)
+ IF new sibling exists:
+ → Close immediately (Sibling Rule)
+ Emit: "
+ Set _openStringPath = null
+```
+
+**Real example (Line 4):**
+```
+_pendingStrings = {
+ "days[0].activities[0].title": "",
+ "days[0].activities[0].type": "Sightseeing"
+}
+
+Current values:
+ title = "Morning Game Drive" (was "")
+ type = "Sightseeing" (unchanged)
+
+Categorize:
+ COMPLETE = [type]
+ CHANGED = [title]
+
+Emit COMPLETE first:
+ type: no siblings emitted yet → no comma
+ Emit: "type":"Sightseeing"
+ Add to _emittedPaths
+
+Emit CHANGED:
+ title: type already emitted (sibling) → needs comma
+ Emit: ,"title":"Morning Game Drive
+ Set _openStringPath = "days[0].activities[0].title"
+ Add to _emittedPaths
+
+Check for new siblings of title:
+ "description" is NEW at same level!
+ → Sibling Rule: close title immediately
+ Emit: "
+ → Then handle description in Step C
+
+Output: "type":"Sightseeing","title":"Morning Game Drive"
+```
+
+---
+
+## Detailed Step C: Process New Content
+
+```
+┌─────────────────────────────────────────────────────────────────────────────┐
+│ STEP C: PROCESS NEW CONTENT │
+└─────────────────────────────────────────────────────────────────────────────┘
+
+For each new path in current that wasn't in previous:
+ │
+ ├─► IF value is NON-GROWABLE (number, bool, null):
+ │ needsComma = any sibling already in _emittedPaths
+ │ Emit complete: [,]"path":value
+ │ Add path to _emittedPaths
+ │
+ └─► IF value is GROWABLE (string, object, array):
+ Group all new GROWABLE items BY PARENT (siblings only):
+ │
+ ├─► For each parent with new growable items:
+ │ Count new growable items at this parent
+ │ │
+ │ ├─► IF 1 new growable item at this parent:
+ │ │ IF no open value (_openStringPath is null AND no pending):
+ │ │ │
+ │ │ ├─► IF string:
+ │ │ │ needsComma = any sibling in _emittedPaths
+ │ │ │ Emit open: [,]"path":"value (no closing quote)
+ │ │ │ Set _openStringPath = path
+ │ │ │ Add to _emittedPaths
+ │ │ │ Update _emittedStrings[path] = value
+ │ │ │
+ │ │ └─► IF object or array:
+ │ │ needsComma = any sibling in _emittedPaths
+ │ │ Emit opening: [,]"path":{ or [,]"path":[
+ │ │ Push to _openStructures
+ │ │ Add path to _emittedPaths
+ │ │ Recursively process children
+ │ │
+ │ │ ELSE (already have open value):
+ │ │ IF string: Add to _pendingStrings (wait for next chunk)
+ │ │ IF container: Add to _pendingContainers (wait for next chunk)
+ │ │
+ │ └─► IF 2+ new growable items at this parent:
+ │ For each growable item:
+ │ IF string: Add to _pendingStrings
+ │ IF container: Add to _pendingContainers
+ │ Do NOT emit values for these
+ │ (Will resolve in next chunk to see which one changes)
+```
+
+**Note**: We count new growable items per parent level, not globally. If we get:
+```json
+{"a": {"x": "hello"}, "b": "world"}
+```
+- `a.x` has 1 new growable item at parent `a`
+- `b` has 1 new growable item at parent root
+- These are NOT siblings, so each is handled separately
+- First one encountered becomes the open value
+- Second goes to pending (can only have one open value at a time)
+
+**Example - 1 new string (Line 7):**
+```
+Previous: days[0].activities[1] = {type: ""}
+Current: days[0].activities[1] = {type: "FoodAndDining", title: "Lunch"}
+
+After Step A closes type (sibling rule):
+ _openStringPath = null
+ _emittedPaths contains "days[0].activities[1].type"
+
+Step C - New content:
+ "days[0].activities[1].title" is new
+ Parent = "days[0].activities[1]"
+ 1 new string at this parent
+ "type" already in _emittedPaths (sibling) → needs comma
+
+Action:
+ Emit: ,"title":"Lunch
+ Set _openStringPath = "days[0].activities[1].title"
+ Add to _emittedPaths
+
+Output: ,"title":"Lunch
+```
+
+**Example - 2+ new strings (Line 3):**
+```
+Previous: days[0].activities = []
+Current: days[0].activities = [{title: "", type: "Sightseeing"}]
+
+Step C - New content:
+ days[0].activities[0] is new object
+ Parent of this object = days[0].activities
+ No siblings emitted → no comma for object
+
+ Emit: {
+ Push to _openStructures
+ Add "days[0].activities[0]" to _emittedPaths
+
+ Inside it: title and type (2 strings!)
+ Parent = days[0].activities[0]
+ 2 new strings at this parent → pending
+
+ Add both to pending:
+ _pendingStrings = {
+ "days[0].activities[0].title": "",
+ "days[0].activities[0].type": "Sightseeing"
+ }
+ Do NOT emit string values
+
+Output: {
+```
+
+---
+
+## Complete Real-World Walkthrough
+
+### Line 1
+```json
+{"days": [{"subtitle": "Day"}]}
+```
+
+**First chunk processing:**
+- Parse and flatten:
+ - Root object
+ - `days` = array
+ - `days[0]` = object
+ - `days[0].subtitle` = "Day"
+- Process containers:
+ - Root: emit `{`, push to _openStructures
+ - `days`: emit `"days":[`, push to _openStructures
+ - `days[0]`: emit `{`, push to _openStructures
+- Process strings at `days[0]`:
+ - 1 string (`subtitle`), no open string yet
+ - Emit open: `"subtitle":"Day` (no closing quote)
+ - Set _openStringPath = "days[0].subtitle"
+
+```
+Output: {"days":[{"subtitle":"Day
+ ↑ no closing quote
+State:
+ _openStringPath = "days[0].subtitle"
+ _emittedStrings = {"days[0].subtitle": "Day"}
+ _emittedPaths = {"days", "days[0]", "days[0].subtitle"}
+ _openStructures = [root, days, days[0]]
+```
+
+### Line 2
+```json
+{"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": []}]}
+```
+
+**Step A - Handle open string:**
+- _openStringPath = "days[0].subtitle"
+- emitted = "Day"
+- current = "Day 1: Arrival and Wildlife Safari"
+- Check siblings at days[0]: `activities` is NEW!
+- Sibling Rule → subtitle is COMPLETE
+
+```
+extension = " 1: Arrival and Wildlife Safari"
+Emit: 1: Arrival and Wildlife Safari"
+_openStringPath = null
+```
+
+**Step C - New content:**
+- `days[0].activities` is new (empty array)
+- Emit: ,"activities":[
+- Push to _openStructures
+
+```
+Output for Line 2: 1: Arrival and Wildlife Safari","activities":[
+
+State:
+ _openStringPath = null
+ _openStructures = [root, days, days[0], days[0].activities]
+```
+
+### Line 3
+```json
+{"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": [{"title": "", "type": "Sightseeing"}]}]}
+```
+
+**Step A - No open string** (was closed in Line 2)
+
+**Step C - New content:**
+- `days[0].activities[0]` is new (object)
+- No siblings in activities array yet → no comma
+- Emit: `{`
+- Push to _openStructures
+- Add to _emittedPaths
+- Inside: title="" and type="Sightseeing" (2 strings at same parent!)
+- Pending Rule → add both to pending, don't emit string values
+
+```
+Emit: {
+_pendingStrings = {
+ "days[0].activities[0].title": "",
+ "days[0].activities[0].type": "Sightseeing"
+}
+
+Output for Line 3: {
+
+State:
+ _openStructures = [..., days[0].activities[0]]
+ _emittedPaths += {"days[0].activities[0]"}
+ _pendingStrings = 2 entries
+```
+
+### Line 4
+```json
+{"days": [{"activities": [{"type": "Sightseeing", "description": "Embark", "title": "Morning Game Drive"}], "subtitle": "Day 1: Arrival and Wildlife Safari"}]}
+```
+
+**Step A - No open string**
+
+**Step B - Resolve pending:**
+- type: "Sightseeing" → "Sightseeing" = UNCHANGED → complete
+- title: "" → "Morning Game Drive" = CHANGED → active
+
+```
+Emit for type: "type":"Sightseeing"
+Emit for title: ,"title":"Morning Game Drive
+_openStringPath = "days[0].activities[0].title"
+```
+
+Check for siblings of title:
+- `description` is NEW at same level!
+- Sibling Rule → title is COMPLETE
+
+```
+Emit: "
+_openStringPath = null
+```
+
+**Step C - New content:**
+- `description` = "Embark" is new
+- 1 new string
+
+```
+Emit: ,"description":"Embark
+_openStringPath = "days[0].activities[0].description"
+
+Output for Line 4: "type":"Sightseeing","title":"Morning Game Drive","description":"Embark
+
+State:
+ _openStringPath = "days[0].activities[0].description"
+ _emittedStrings[...description] = "Embark"
+```
+
+### Line 5
+```json
+{"days": [{"activities": [{"description": "Embark on a thrilling morning game drive to witness the Great Migration in all its glory.", "title": "Morning Game Drive", "type": "Sightseeing"}], "subtitle": "Day 1: Arrival and Wildlife Safari"}]}
+```
+
+**Step A - Handle open string:**
+- _openStringPath = "days[0].activities[0].description"
+- emitted = "Embark"
+- current = "Embark on a thrilling..."
+- Check siblings: none new
+- Check parent level: nothing new
+- Value CHANGED → emit extension, keep open
+
+```
+extension = " on a thrilling..."
+Emit: on a thrilling...
+_openStringPath still set
+
+Output for Line 5: on a thrilling...
+```
+
+### Line 6
+```json
+{"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": [{"description": "Embark on a thrilling morning game drive to witness the Great Migration in all its glory.", "type": "Sightseeing", "title": "Morning Game Drive"}, {"type": ""}]}]}
+```
+
+**Step A - Handle open string:**
+- _openStringPath = "days[0].activities[0].description"
+- emitted = "Embark on a thrilling..."
+- current = "...full text..."
+- Check siblings at activities[0]: none new
+- Check parent level: `days[0].activities[1]` is NEW!
+- Parent-Level Rule → activities[0] is COMPLETE
+
+```
+extension = " morning game drive to witness the Great Migration in all its glory."
+Emit extension: ...
+Close description: "
+Close activities[0] object: }
+_openStringPath = null
+```
+
+**Step C - New content:**
+- `days[0].activities[1]` is new object
+- Inside: type="" (1 string)
+
+```
+Emit: ,{
+Emit: "type":"
+_openStringPath = "days[0].activities[1].type"
+_emittedStrings[...type] = ""
+
+Output for Line 6: morning game drive to witness the Great Migration in all its glory."},{"type":"
+```
+
+### Line 7
+```json
+{"days": [{"subtitle": "Day 1: Arrival and Wildlife Safari", "activities": [{"title": "Morning Game Drive", "description": "...", "type": "Sightseeing"}, {"type": "FoodAndDining", "title": "Lunch"}]}]}
+```
+
+**Step A - Handle open string:**
+- _openStringPath = "days[0].activities[1].type"
+- emitted = ""
+- current = "FoodAndDining"
+- Check siblings: `title` is NEW!
+- Sibling Rule → type is COMPLETE
+
+```
+extension = "FoodAndDining"
+Emit: FoodAndDining"
+_openStringPath = null
+```
+
+**Step C - New content:**
+- `title` = "Lunch" is new
+- 1 new string
+
+```
+Emit: ,"title":"Lunch
+_openStringPath = "days[0].activities[1].title"
+
+Output for Line 7: FoodAndDining","title":"Lunch
+```
+
+### Line 8
+```json
+{"days": [{"activities": [{"type": "Sightseeing", "description": "...", "title": "Morning Game Drive"}, {"description": "Enjoy", "title": "Lunch at Restaurant 1", "type": "FoodAndDining"}], "subtitle": "Day 1: Arrival and Wildlife Safari"}]}
+```
+
+**Step A - Handle open string:**
+- _openStringPath = "days[0].activities[1].title"
+- emitted = "Lunch"
+- current = "Lunch at Restaurant 1"
+- Check siblings: `description` is NEW!
+- Sibling Rule → title is COMPLETE
+
+```
+extension = " at Restaurant 1"
+Emit: at Restaurant 1"
+_openStringPath = null
+```
+
+**Step C - New content:**
+- `description` = "Enjoy" is new
+- 1 new string
+
+```
+Emit: ,"description":"Enjoy
+_openStringPath = "days[0].activities[1].description"
+
+Output for Line 8: at Restaurant 1","description":"Enjoy
+```
+
+---
+
+## Edge Cases
+
+### Empty string grows
+```
+Line 3: "title": ""
+Line 4: "title": "Morning Game Drive"
+```
+- Empty string is just a string with length 0
+- Extension = "Morning Game Drive"[0..] = "Morning Game Drive"
+- No special handling needed - treat `""` like any other string
+
+### Empty containers get populated
+```
+Line 2: "activities": []
+Line 3: "activities": [{"title": ""}]
+```
+- Empty array `[]` → emit `[` only, push to _openStructures
+- Do NOT emit closing `]`
+- When items appear → emit them normally
+- Closing brackets emitted when moving to sibling/parent or at finalize
+
+**Note on flattening**: Empty containers ARE stored in the flattened state dictionary
+with their JsonValueKind (Array or Object). This allows us to:
+1. Know the container exists even with no children
+2. Detect when children are added (container grew)
+
+### Nested container changes propagate up
+
+Containers (arrays and objects) can have nested children that grow. When determining if a container is "changing", we must check ALL descendants, not just direct children.
+
+**The Rule**: A container is "still active/growing" if ANY descendant path changes.
+
+**Example - Nested array with growing string:**
+```
+Previous: {"items": [{"name": "Jo"}]}
+Current: {"items": [{"name": "John"}]}
+```
+- `items` is an array containing an object
+- The object contains a string `name` that grew ("Jo" → "John")
+- Even though `items[0]` exists in both, the string inside changed
+- Therefore `items` (the array) is still "active" - don't close it
+
+**Example - Deep nesting:**
+```
+Previous: {"root": {"level1": {"level2": {"value": "He"}}}}
+Current: {"root": {"level1": {"level2": {"value": "Hello"}}}}
+```
+- `value` is the actual string that changed
+- But `level2`, `level1`, and `root` are ALL still active because a descendant changed
+- None of these containers should be closed
+
+**Detection Algorithm:**
+When checking if a container at path P is "complete" vs "still active":
+1. Find all current paths that START WITH P (all descendants)
+2. Find all previous paths that START WITH P
+3. If ANY descendant value changed → container is still active
+4. Only if ALL descendants are unchanged → container is complete
+
+**Why this matters for pending:**
+When we have pending containers (e.g., a string and an array both appeared):
+```
+Previous: {"days": [{}]}
+Current: {"days": [{"subtitle": "", "activities": []}]}
+```
+- `subtitle` (string) and `activities` (array) are both pending
+- Next chunk:
+ ```
+ Current: {"days": [{"subtitle": "Day 1", "activities": []}]}
+ ```
+- `subtitle` changed ("" → "Day 1") → subtitle is the active one
+- `activities` has no new children → activities is complete
+- BUT if instead:
+ ```
+ Current: {"days": [{"subtitle": "", "activities": [{"type": ""}]}]}
+ ```
+- `subtitle` unchanged ("" → "")
+- `activities` now has children → activities is the active one
+- The array "grew" even though `activities` itself didn't change - its DESCENDANTS did
+
+### Array grows by new item
+```
+Line 5: activities has 1 item
+Line 6: activities has 2 items
+```
+- Detect new array index (activities[1])
+- This triggers Parent-Level Rule for activities[0]
+- Close activities[0], emit `,{`, process new item
+
+### Deep nesting
+- Paths handle arbitrary depth: `days[0].activities[2].details.notes[0]`
+- Stack of _openStructures handles closing in correct order
+- Parent detection walks up the path segments
+
+### New strings at different nesting levels
+```
+Previous: {"count": 5}
+Current: {"count": 5, "a": {"x": "hello"}, "b": "world"}
+```
+- `a.x` is at parent `a` (1 string at this level)
+- `b` is at parent root (1 string at this level)
+- These are NOT siblings - different parents
+- We can only have ONE open string at a time
+- First encountered (e.g., `a.x`) becomes _openStringPath
+- Second (`b`) goes to pending even though it's alone at its level
+- Next chunk resolves: whichever changed is active (per invariant, at most one changes)
+
+### All pending strings complete (none changed)
+```
+Line N: Two strings added to pending
+Line N+1: Both strings have same values (unchanged)
+```
+- Both are COMPLETE
+- Emit both with closing quotes
+- No _openStringPath is set
+- Continue to Step C for new content
+
+---
+
+## Key Decision Points
+
+### When to close the open string?
+
+1. **Sibling Rule**: New property at same level
+2. **Parent Rule**: New content at higher level
+3. **Unchanged Rule**: Value same as previous chunk
+4. **Finalize**: End of stream
+
+### When to use pending?
+
+- First chunk has 2+ strings
+- 2+ new strings appear in same chunk
+- NEVER emit pending strings immediately - always wait for next chunk
+
+### How to calculate "same level"?
+
+Two paths are at the same level (siblings) if they have the same parent:
+- `days[0].title` and `days[0].subtitle` → same parent `days[0]` ✓
+- `days[0].title` and `days[1].title` → different parents ✗
+- `days[0].activities[0].title` and `days[0].activities[0].type` → same parent ✓
+
+### How to detect parent-level changes?
+
+For open string at path P, check if any new content appeared at:
+- P's parent path
+- P's grandparent path
+- etc.
+
+Example: If open string is at `days[0].activities[0].description`:
+- Parent = `days[0].activities[0]`
+- Grandparent = `days[0].activities`
+- If `days[0].activities[1]` appears → parent-level change!
+
+### How to handle empty containers?
+
+- Emit `[` or `{` only (no closing bracket)
+- Add to _openStructures
+- Close when moving to sibling/parent or at finalize
+
+---
+
+## Key Invariants
+
+1. **At most ONE growable value is open** at any time (never two)
+2. **Pending items (strings and containers) are NEVER emitted** until resolved in next chunk
+3. **Extension = current[emitted.Length..]** - strings only grow, never shrink
+4. **Order: Step A (open) → Step B (pending) → Step C (new)** - always this order
+5. **2+ new growable items at same parent = pending** - never guess which is active
+6. **New sibling = complete** - AI moved on horizontally
+7. **Parent-level change = complete** - AI moved on vertically
+8. **Track by path, not position** - property order doesn't matter
+9. **Containers stay open** until sibling/parent change or finalize
+10. **Empty containers are valid** - `{}` and `[]` can get populated later
+11. **Comma before sibling** - check _emittedPaths to know if comma needed
+12. **At most ONE value changes per chunk** - if Step B finds 2+ changed (strings or containers), that's an error
+13. **Growable types: strings, arrays, objects** - all can grow and need pending logic
+14. **Non-growable types: numbers, bools, null** - always complete immediately
+15. **Nested changes propagate up** - if ANY descendant of a container changes, the container is still active
+16. **Complete containers emit all content** - when a pending container is resolved as complete, emit its full content (opening bracket, all children recursively, closing bracket)
+17. **Structure closing is level-aware** - use `CloseStructuresDownTo` to close structures when emitting at a different tree level
+18. **Pending resolution preserves new pending** - when resolving pending items, NEW pending items may be added during container emission (e.g., emitting a pending array creates new pending strings for its children). Only remove the original pending items, not the newly added ones.
+19. **Array iteration closes previous items** - when iterating array items in first chunk, close any pending items from the previous array item before moving to the next one
+20. **Container growth detection compares key sets** - when checking if a pending container grew, compare the SET of descendant keys, not just the count. An empty object `{}` becoming a nested object changes the keys even if the count stays the same.
+
+---
+
+## Structure Management
+
+### CloseStructuresDownTo Algorithm
+
+When we need to emit content at a different level in the JSON tree, we must first close any open structures that are not ancestors of the target path.
+
+```
+CloseStructuresDownTo(targetPath):
+ while _openStructures is not empty:
+ (topPath, isArray) = peek top of stack
+
+ // Check if targetPath is at or inside topPath
+ isPrefix = targetPath.startsWith(topPath) AND
+ (lengths equal OR next char is '.' or '[')
+
+ if topPath is root ("") OR isPrefix:
+ break // Don't close - we're inside this structure
+
+ pop from stack
+ emit ']' if isArray else '}'
+```
+
+**Example:**
+```
+_openStructures = [("", false), ("days", true), ("days[0]", false), ("days[0].activities", true)]
+
+We want to emit at "days[1]" (new array item in days)
+
+1. Check "days[0].activities" - "days[1]" doesn't start with this → close with ']'
+2. Check "days[0]" - "days[1]" doesn't start with this → close with '}'
+3. Check "days" - "days[1]" DOES start with "days" → stop
+
+Result: emitted "]}" and stack is now [("", false), ("days", true)]
+```
+
+### When Structure Closing Happens
+
+1. **Before emitting a new property** - `EmitNewProperty` calls `CloseStructuresDownTo(parentPath)`
+2. **Before emitting a pending string** - `EmitPendingString` calls `CloseStructuresDownTo(parentPath)`
+3. **Before emitting a new array item** - `EmitNewArrayItem` calls `CloseStructuresDownTo(arrayPath)`
+
+This ensures we're always at the correct nesting level before emitting new content.
+
+---
+
+## First Chunk vs Subsequent Chunk
+
+The implementation handles the first chunk differently from subsequent chunks:
+
+### First Chunk (ProcessFirstChunk)
+- No previous state to compare against
+- Uses `EmitStructure` to recursively process the JSON tree
+- Counts growables at each level to decide open vs pending
+- Emits structure (brackets) and handles string ambiguity
+
+### Subsequent Chunks (ProcessSubsequentChunk)
+- Has previous state for comparison
+- Follows the Step A → Step B → Step C order
+- Uses `ProcessNewContent` to find and emit new properties/items
+- Can detect siblings and parent-level changes
+
+## Critical Rules Checklist
+
+### NEVER Do These Things
+
+1. **NEVER defer all output to the end** - This defeats the entire purpose of streaming
+2. **NEVER accumulate all chunks and process at the end** - Each chunk must produce output
+3. **NEVER rely on property order** - Use path-based comparison, not positional
+4. **NEVER close a string prematurely** - Only close when CERTAIN it won't grow
+5. **NEVER re-serialize to find diff** - Compare objects structurally, emit directly
+
+### ALWAYS Do These Things
+
+1. **ALWAYS use intermediate deserialization** - Parse each chunk to dictionary for comparison
+2. **ALWAYS compare by path** - Flatten objects to `path → value` dictionaries
+3. **ALWAYS track what's "open"** - Use stack to know what needs closing tokens
+4. **ALWAYS emit extension BEFORE closing** - When closing a string that grew, emit extension first
+5. **ALWAYS validate concatenation** - Concatenate all chunks, parse as JSON, compare to final
+
+### Order of Operations
+
+When processing a chunk where a new property appears (signaling completion):
+
+1. **FIRST**: Emit any remaining string extension (the part that grew since last chunk)
+2. **THEN**: Emit the closing quote
+3. **THEN**: Emit new content (comma, new property, etc.)
+
+This prevents truncation of the final string extension.
+
+---
+
+## Why Naive Approaches Fail
+
+### The String Diff Problem
+
+A naive approach of "serialize with open strings, diff, emit diff" fails because:
+
+1. **Line 2**: We serialize with `subtitle` open (no closing quote):
+ `...Safari,"activities":[` (note: no quote after Safari)
+
+2. **Line 3**: We serialize with `type` open (different string):
+ `...Safari","activities":[{"title":"","type":"Sightseeing` (quote after Safari)
+
+3. When we diff these, they diverge at position 55 where one has `,` and the other has `"`.
+ The diff produces content that DUPLICATES properties!
+
+### The Root Cause
+
+The **closing quote for a string is NOT at the end of our emitted output**. It's embedded in the middle, before subsequent content. When we change which string is "open", the quote position moves, causing serializations to diverge unexpectedly.
+
+### The Solution
+
+Track what we've **ACTUALLY EMITTED** separately from any serialization:
+
+1. Emit content immediately, but track that a string is "open" (withheld quote)
+2. When a new property appears (signaling previous string is complete):
+ - Emit the closing quote FIRST
+ - THEN emit the new content
+3. The emitted stream is what we actually output, not a re-serialization
+
+---
+
+## Files
+
+- **Implementation**: `src/AI/src/Essentials.AI/JsonStreamChunker.cs`
+- **Tests**: `src/AI/tests/Essentials.AI.UnitTests/JsonStreamChunkerTests.cs`
+- **Test data**: `src/AI/tests/Essentials.AI.UnitTests/TestData/ObjectStreams/*.jsonl`
diff --git a/src/AI/samples/AppleNative/.gitignore b/src/AI/samples/AppleNative/.gitignore
new file mode 100644
index 000000000000..fda4de3a857d
--- /dev/null
+++ b/src/AI/samples/AppleNative/.gitignore
@@ -0,0 +1,62 @@
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## Obj-C/Swift specific
+*.hmap
+
+## App packaging
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+#
+# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
+# Packages/
+# Package.pins
+# Package.resolved
+# *.xcodeproj
+#
+# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
+# hence it is not needed unless you have added a package configuration file to your project
+# .swiftpm
+
+.build/
+
+# CocoaPods
+#
+# We recommend against adding the Pods directory to your .gitignore. However
+# you should judge for yourself, the pros and cons are mentioned at:
+# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
+#
+# Pods/
+#
+# Add this line if you want to avoid checking in source code from the Xcode workspace
+# *.xcworkspace
+
+# Carthage
+#
+# Add this line if you want to avoid checking in source code from Carthage dependencies.
+# Carthage/Checkouts
+
+Carthage/Build/
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo.
+# Instead, use fastlane to re-generate the screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/#source-control
+
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
\ No newline at end of file
diff --git a/src/AI/samples/AppleNative/EssentialsAI.xcworkspace/contents.xcworkspacedata b/src/AI/samples/AppleNative/EssentialsAI.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000000..a4007c161b82
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAI.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/src/AI/samples/AppleNative/EssentialsAI.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/src/AI/samples/AppleNative/EssentialsAI.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 000000000000..0c67376ebacb
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAI.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample.xcodeproj/project.pbxproj b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample.xcodeproj/project.pbxproj
new file mode 100644
index 000000000000..94b8524beed1
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample.xcodeproj/project.pbxproj
@@ -0,0 +1,375 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 344600CD2ECBA12600425C44 /* EssentialsAI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 344600CC2ECBA12600425C44 /* EssentialsAI.framework */; };
+ 344600CE2ECBA12600425C44 /* EssentialsAI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 344600CC2ECBA12600425C44 /* EssentialsAI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
+/* End PBXBuildFile section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 344600CF2ECBA12600425C44 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ 344600CE2ECBA12600425C44 /* EssentialsAI.framework in Embed Frameworks */,
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 344600AE2ECBA0C300425C44 /* EssentialsAISample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = EssentialsAISample.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 344600CC2ECBA12600425C44 /* EssentialsAI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = EssentialsAI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 344600C52ECBA0C400425C44 /* Exceptions for "EssentialsAISample" folder in "EssentialsAISample" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 344600AD2ECBA0C300425C44 /* EssentialsAISample */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
+/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 344600B02ECBA0C300425C44 /* EssentialsAISample */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 344600C52ECBA0C400425C44 /* Exceptions for "EssentialsAISample" folder in "EssentialsAISample" target */,
+ );
+ path = EssentialsAISample;
+ sourceTree = "";
+ };
+/* End PBXFileSystemSynchronizedRootGroup section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 344600AB2ECBA0C300425C44 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 344600CD2ECBA12600425C44 /* EssentialsAI.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 344600A52ECBA0C300425C44 = {
+ isa = PBXGroup;
+ children = (
+ 344600B02ECBA0C300425C44 /* EssentialsAISample */,
+ 344600CB2ECBA12600425C44 /* Frameworks */,
+ 344600AF2ECBA0C300425C44 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 344600AF2ECBA0C300425C44 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 344600AE2ECBA0C300425C44 /* EssentialsAISample.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 344600CB2ECBA12600425C44 /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 344600CC2ECBA12600425C44 /* EssentialsAI.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 344600AD2ECBA0C300425C44 /* EssentialsAISample */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 344600C62ECBA0C400425C44 /* Build configuration list for PBXNativeTarget "EssentialsAISample" */;
+ buildPhases = (
+ 344600AA2ECBA0C300425C44 /* Sources */,
+ 344600AB2ECBA0C300425C44 /* Frameworks */,
+ 344600AC2ECBA0C300425C44 /* Resources */,
+ 344600CF2ECBA12600425C44 /* Embed Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 344600B02ECBA0C300425C44 /* EssentialsAISample */,
+ );
+ name = EssentialsAISample;
+ packageProductDependencies = (
+ );
+ productName = EssentialsAISample;
+ productReference = 344600AE2ECBA0C300425C44 /* EssentialsAISample.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 344600A62ECBA0C300425C44 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastUpgradeCheck = 2610;
+ TargetAttributes = {
+ 344600AD2ECBA0C300425C44 = {
+ CreatedOnToolsVersion = 26.1;
+ };
+ };
+ };
+ buildConfigurationList = 344600A92ECBA0C300425C44 /* Build configuration list for PBXProject "EssentialsAISample" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 344600A52ECBA0C300425C44;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 344600AF2ECBA0C300425C44 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 344600AD2ECBA0C300425C44 /* EssentialsAISample */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 344600AC2ECBA0C300425C44 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 344600AA2ECBA0C300425C44 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 344600C72ECBA0C400425C44 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 42GDTGK33W;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = EssentialsAISample/Info.plist;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UIMainStoryboardFile = Main;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.maui.EssentialsAISample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator xros xrsimulator";
+ SUPPORTS_MACCATALYST = YES;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ TARGETED_DEVICE_FAMILY = "1,2,3,7";
+ };
+ name = Debug;
+ };
+ 344600C82ECBA0C400425C44 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = 42GDTGK33W;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = EssentialsAISample/Info.plist;
+ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
+ INFOPLIST_KEY_UIMainStoryboardFile = Main;
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.maui.EssentialsAISample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator xros xrsimulator";
+ SUPPORTS_MACCATALYST = YES;
+ SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ TARGETED_DEVICE_FAMILY = "1,2,3,7";
+ };
+ name = Release;
+ };
+ 344600C92ECBA0C400425C44 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.1;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ };
+ name = Debug;
+ };
+ 344600CA2ECBA0C400425C44 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.1;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = iphoneos;
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 344600A92ECBA0C300425C44 /* Build configuration list for PBXProject "EssentialsAISample" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 344600C92ECBA0C400425C44 /* Debug */,
+ 344600CA2ECBA0C400425C44 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 344600C62ECBA0C400425C44 /* Build configuration list for PBXNativeTarget "EssentialsAISample" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 344600C72ECBA0C400425C44 /* Debug */,
+ 344600C82ECBA0C400425C44 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 344600A62ECBA0C300425C44 /* Project object */;
+}
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample.xcodeproj/xcshareddata/xcschemes/EssentialsAISample.xcscheme b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample.xcodeproj/xcshareddata/xcschemes/EssentialsAISample.xcscheme
new file mode 100644
index 000000000000..c2d6bb707979
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample.xcodeproj/xcshareddata/xcschemes/EssentialsAISample.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/AppDelegate.h b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/AppDelegate.h
new file mode 100644
index 000000000000..a73691ea8798
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/AppDelegate.h
@@ -0,0 +1,14 @@
+//
+// AppDelegate.h
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import
+
+@interface AppDelegate : UIResponder
+
+
+@end
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/AppDelegate.m b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/AppDelegate.m
new file mode 100644
index 000000000000..1e00653cbf2e
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/AppDelegate.m
@@ -0,0 +1,40 @@
+//
+// AppDelegate.m
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import "AppDelegate.h"
+
+@interface AppDelegate ()
+
+@end
+
+@implementation AppDelegate
+
+
+- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
+ // Override point for customization after application launch.
+ return YES;
+}
+
+
+#pragma mark - UISceneSession lifecycle
+
+
+- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options {
+ // Called when a new scene session is being created.
+ // Use this method to select a configuration to create the new scene with.
+ return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role];
+}
+
+
+- (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet *)sceneSessions {
+ // Called when the user discards a scene session.
+ // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
+ // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
+}
+
+
+@end
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/AccentColor.colorset/Contents.json b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 000000000000..eb8789700816
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "colors" : [
+ {
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/AppIcon.appiconset/Contents.json b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 000000000000..2305880107db
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,35 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/Contents.json b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/Contents.json
new file mode 100644
index 000000000000..73c00596a7fc
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Base.lproj/LaunchScreen.storyboard b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 000000000000..865e9329f376
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Base.lproj/Main.storyboard b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Base.lproj/Main.storyboard
new file mode 100644
index 000000000000..808a21ce779b
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Base.lproj/Main.storyboard
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Info.plist b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Info.plist
new file mode 100644
index 000000000000..81ed29b76cfb
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/Info.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneConfigurationName
+ Default Configuration
+ UISceneDelegateClassName
+ SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/SceneDelegate.h b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/SceneDelegate.h
new file mode 100644
index 000000000000..9c6cb452c260
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/SceneDelegate.h
@@ -0,0 +1,15 @@
+//
+// SceneDelegate.h
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import
+
+@interface SceneDelegate : UIResponder
+
+@property (strong, nonatomic) UIWindow * window;
+
+@end
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/SceneDelegate.m b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/SceneDelegate.m
new file mode 100644
index 000000000000..07ddaa782b02
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/SceneDelegate.m
@@ -0,0 +1,57 @@
+//
+// SceneDelegate.m
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import "SceneDelegate.h"
+
+@interface SceneDelegate ()
+
+@end
+
+@implementation SceneDelegate
+
+
+- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions {
+ // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
+ // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
+ // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
+}
+
+
+- (void)sceneDidDisconnect:(UIScene *)scene {
+ // Called as the scene is being released by the system.
+ // This occurs shortly after the scene enters the background, or when its session is discarded.
+ // Release any resources associated with this scene that can be re-created the next time the scene connects.
+ // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
+}
+
+
+- (void)sceneDidBecomeActive:(UIScene *)scene {
+ // Called when the scene has moved from an inactive state to an active state.
+ // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
+}
+
+
+- (void)sceneWillResignActive:(UIScene *)scene {
+ // Called when the scene will move from an active state to an inactive state.
+ // This may occur due to temporary interruptions (ex. an incoming phone call).
+}
+
+
+- (void)sceneWillEnterForeground:(UIScene *)scene {
+ // Called as the scene transitions from the background to the foreground.
+ // Use this method to undo the changes made on entering the background.
+}
+
+
+- (void)sceneDidEnterBackground:(UIScene *)scene {
+ // Called as the scene transitions from the foreground to the background.
+ // Use this method to save data, release shared resources, and store enough scene-specific state information
+ // to restore the scene back to its current state.
+}
+
+
+@end
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/ViewController.h b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/ViewController.h
new file mode 100644
index 000000000000..083682194106
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/ViewController.h
@@ -0,0 +1,14 @@
+//
+// ViewController.h
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import
+
+@interface ViewController : UIViewController
+
+
+@end
+
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/ViewController.m b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/ViewController.m
new file mode 100644
index 000000000000..85b26a190d08
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/ViewController.m
@@ -0,0 +1,194 @@
+//
+// ViewController.m
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import "ViewController.h"
+
+#import "EssentialsAI/EssentialsAI-Swift.h"
+
+// Block typedefs for AI callbacks
+typedef void (^AIUpdateBlock)(ResponseUpdateNative *_Nullable result);
+typedef void (^AICompletionBlock)(ChatResponseNative *_Nullable result,
+ NSError *_Nullable error);
+
+#pragma mark - Tools
+
+// Find points of interest tool for Maui itinerary
+@interface FindPointsOfInterestTool : NSObject
+@end
+
+@implementation FindPointsOfInterestTool
+
+- (NSString *)name {
+ return @"findPointsOfInterest";
+}
+
+- (NSString *)desc {
+ return @"Finds points of interest for a landmark.";
+}
+
+- (NSString *)argumentsSchema {
+ return @"{"
+ "\"type\":\"object\","
+ "\"properties\":{"
+ "\"pointOfInterest\":{"
+ "\"description\":\"This is the type of destination to look up for.\","
+ "\"type\":\"string\","
+ "\"enum\":[\"Cafe\",\"Campground\",\"Hotel\",\"Marina\",\"Museum\",\"NationalMonument\",\"Restaurant\"]"
+ "},"
+ "\"naturalLanguageQuery\":{"
+ "\"description\":\"The natural language query of what to search for.\","
+ "\"type\":\"string\""
+ "}"
+ "},"
+ "\"required\":[\"pointOfInterest\",\"naturalLanguageQuery\"]"
+ "}";
+}
+
+- (NSString *)outputSchema {
+ return @"{\"type\":\"string\"}";
+}
+
+- (void)callWithArguments:(NSString *)arguments
+ completion:(void (^)(NSString *))completion {
+ // Parse the arguments
+ NSError *error = nil;
+ NSDictionary *argsDict = [NSJSONSerialization JSONObjectWithData:[arguments dataUsingEncoding:NSUTF8StringEncoding]
+ options:0
+ error:&error];
+
+ NSString *poiType = argsDict[@"pointOfInterest"] ?: @"unknown";
+
+ // Generate results matching the C# implementation format
+ NSString *result;
+ if ([poiType isEqualToString:@"Cafe"]) {
+ result = @"There are these Cafe in Maui: Cafe 1, Cafe 2, Cafe 3";
+ } else if ([poiType isEqualToString:@"Campground"]) {
+ result = @"There are these Campground in Maui: Campground 1, Campground 2, Campground 3";
+ } else if ([poiType isEqualToString:@"Hotel"]) {
+ result = @"There are these Hotel in Maui: Hotel 1, Hotel 2, Hotel 3";
+ } else if ([poiType isEqualToString:@"Marina"]) {
+ result = @"There are these Marina in Maui: Marina 1, Marina 2, Marina 3";
+ } else if ([poiType isEqualToString:@"Museum"]) {
+ result = @"There are these Museum in Maui: Museum 1, Museum 2, Museum 3";
+ } else if ([poiType isEqualToString:@"NationalMonument"]) {
+ result = @"There are these NationalMonument in Maui: The National Rock 1, The National Rock 2, The National Rock 3";
+ } else if ([poiType isEqualToString:@"Restaurant"]) {
+ result = @"There are these Restaurant in Maui: Restaurant 1, Restaurant 2, Restaurant 3";
+ } else {
+ result = [NSString stringWithFormat:@"There are no %@ in Maui", poiType];
+ }
+
+ completion(result);
+}
+
+@end
+
+#pragma mark - View Controller
+
+@implementation ViewController
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ // Set up logging using simple block
+ AppleIntelligenceLogger.log = ^(NSString *message) {
+ NSLog(@"[Native] %@", message);
+ };
+
+ // 1. Create the client
+ ChatClientNative *client = [[ChatClientNative alloc] init];
+
+ // 2. Create system message with Maui description
+ ChatMessageNative *systemMessage = [[ChatMessageNative alloc] init];
+ systemMessage.role = ChatRoleNativeSystem;
+ systemMessage.contents = @[
+ [[TextContentNative alloc] initWithText:@"Your job is to create an itinerary for the person."],
+ [[TextContentNative alloc] initWithText:@"Each day needs an activity, hotel and restaurant."],
+ [[TextContentNative alloc] initWithText:@"Always use the findPointsOfInterest tool to find businesses and activities in Maui, especially hotels and restaurants.\n\nThe point of interest categories may include:"],
+ [[TextContentNative alloc] initWithText:@"Cafe, Campground, Hotel, Marina, Museum, NationalMonument, Restaurant"],
+ [[TextContentNative alloc] initWithText:@"Here is a description of Maui for your reference when considering what activities to generate:"],
+ [[TextContentNative alloc] initWithText:@"The second-largest island in Hawaii, Maui offers a stunning tapestry of volcanic landscapes, lush rainforests, pristine beaches, and dramatic coastal cliffs. Known as the \"Valley Isle,\" Maui is dominated by two massive volcanoes: the dormant Haleakalā in the east and the older, eroded West Maui Mountains. Haleakalā National Park features a massive volcanic crater that reaches over 10,000 feet in elevation, offering breathtaking sunrise views and unique high-altitude ecosystems.\n\nThe island's diverse geography creates distinct climate zones, from arid leeward coasts to verdant windward rainforests receiving over 400 inches of annual rainfall. The famous Road to Hana winds through tropical paradise, passing countless waterfalls, bamboo forests, and dramatic ocean vistas. Maui's volcanic soil supports rich agriculture, including sugarcane, pineapple, coffee, and exotic tropical fruits.\n\nMarine life thrives in Maui's warm Pacific waters. Humpback whales migrate to the shallow channels between the Hawaiian islands from December to May, making Maui one of the world's premier whale-watching destinations. Hawaiian green sea turtles, spinner dolphins, and vibrant coral reefs attract snorkelers and divers year-round. The island's beaches range from golden sand to unique red and black volcanic shores. Native Hawaiian plants such as the silversword, which grows only on Haleakalā's slopes, and endemic bird species like the nēnē (Hawaiian goose) highlight the island's ecological significance and ongoing conservation efforts."]
+ ];
+
+ // 3. Create user message
+ ChatMessageNative *userMessage = [[ChatMessageNative alloc] init];
+ userMessage.role = ChatRoleNativeUser;
+ userMessage.contents = @[
+ [[TextContentNative alloc] initWithText:@"Generate a 3-day itinerary to Maui."],
+ [[TextContentNative alloc] initWithText:@"Give it a fun title and description."],
+ [[TextContentNative alloc] initWithText:@"Here is an example, but don't copy it:"],
+ [[TextContentNative alloc] initWithText:@"{\"title\":\"Onsen Trip to Japan\",\"destinationName\":\"Mt. Fuji\",\"description\":\"Sushi, hot springs, and ryokan with a toddler!\",\"rationale\":\"You are traveling with a child, so climbing Mt. Fuji is probably not an option,\nbut there is lots to do around Kawaguchiko Lake, including Fujikyu.\nI recommend staying in a ryokan because you love hotsprings.\",\"days\":[{\"title\":\"Sushi and Shopping Near Kawaguchiko\",\"subtitle\":\"Spend your final day enjoying sushi and souvenir shopping.\",\"destination\":\"Kawaguchiko Lake\",\"activities\":[{\"type\":\"FoodAndDining\",\"title\":\"The Restaurant serving Sushi\",\"description\":\"Visit an authentic sushi restaurant for lunch.\"},{\"type\":\"Shopping\",\"title\":\"The Plaza\",\"description\":\"Enjoy souvenir shopping at various shops.\"},{\"type\":\"Sightseeing\",\"title\":\"The Beautiful Cherry Blossom Park\",\"description\":\"Admire the beautiful cherry blossom trees in the park.\"},{\"type\":\"HotelAndLodging\",\"title\":\"The Hotel\",\"description\":\"Spend one final evening in the hotspring before heading home.\"}]}]}"]
+ ];
+
+ // 4. Create options with JSON schema and tool
+ ChatOptionsNative *options = [[ChatOptionsNative alloc] init];
+
+ // JSON schema for structured itinerary output
+ options.responseJsonSchema = @"{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"description\":\"A travel itinerary with days and activities.\",\"type\":\"object\",\"properties\":{\"title\":{\"description\":\"An exciting name for the trip.\",\"type\":\"string\"},\"destinationName\":{\"type\":\"string\"},\"description\":{\"type\":\"string\"},\"rationale\":{\"description\":\"An explanation of how the itinerary meets the person's special requests.\",\"type\":\"string\"},\"days\":{\"description\":\"A list of day-by-day plans.\",\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"title\":{\"description\":\"A unique and exciting title for this day plan.\",\"type\":\"string\"},\"subtitle\":{\"type\":\"string\"},\"destination\":{\"type\":\"string\"},\"activities\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"Sightseeing\",\"FoodAndDining\",\"Shopping\",\"HotelAndLodging\"]},\"title\":{\"type\":\"string\"},\"description\":{\"type\":\"string\"}},\"required\":[\"type\",\"title\",\"description\"],\"title\":\"Activity\",\"additionalProperties\":false},\"minItems\":3,\"maxItems\":3}},\"required\":[\"title\",\"subtitle\",\"destination\",\"activities\"],\"title\":\"DayPlan\",\"additionalProperties\":false},\"minItems\":3,\"maxItems\":3}},\"required\":[\"title\",\"destinationName\",\"description\",\"rationale\",\"days\"],\"title\":\"Itinerary\",\"additionalProperties\":false}";
+
+ // Create tool instance
+ FindPointsOfInterestTool *poiTool = [[FindPointsOfInterestTool alloc] init];
+ options.tools = @[ poiTool ];
+
+ // 5. Define callbacks
+ AICompletionBlock completion =
+ ^(ChatResponseNative *_Nullable result, NSError *_Nullable error) {
+ if (error) {
+ NSLog(@"Error from AI: %@", error);
+ return;
+ }
+
+ NSLog(@"AI Response received with %lu messages", (unsigned long)result.messages.count);
+
+ for (ChatMessageNative *msg in result.messages) {
+ NSLog(@"Message role: %ld", (long)msg.role);
+
+ for (AIContentNative *content in msg.contents) {
+ if ([content isKindOfClass:[TextContentNative class]]) {
+ TextContentNative *text = (TextContentNative *)content;
+ NSLog(@" Text: %@", text.text);
+ } else if ([content isKindOfClass:[FunctionCallContentNative class]]) {
+ FunctionCallContentNative *funcCall = (FunctionCallContentNative *)content;
+ NSLog(@" Function Call: %@ (%@)", funcCall.name, funcCall.callId);
+ NSLog(@" Arguments: %@", funcCall.arguments);
+ } else if ([content isKindOfClass:[FunctionResultContentNative class]]) {
+ FunctionResultContentNative *funcResult = (FunctionResultContentNative *)content;
+ NSLog(@" Function Result (%@): %@", funcResult.callId, funcResult.result);
+ }
+ }
+ }
+ };
+
+ AIUpdateBlock update = ^(ResponseUpdateNative *_Nullable result) {
+ if (result.text) {
+ NSLog(@"Stream update: text = %@", result.text);
+ }
+ if (result.toolCallName) {
+ NSLog(@"Stream update: tool = %@ (id=%@)", result.toolCallName, result.toolCallId);
+ if (result.toolCallArguments) {
+ NSLog(@" arguments = %@", result.toolCallArguments);
+ }
+ if (result.toolCallResult) {
+ NSLog(@" result = %@", result.toolCallResult);
+ }
+ }
+ };
+
+ // 6. Call streamResponse with system and user messages
+ NSArray *messages = @[ systemMessage, userMessage ];
+
+ CancellationTokenNative *streamToken =
+ [client streamResponseWithMessages:messages
+ options:options
+ onUpdate:update
+ onComplete:completion];
+
+ // Keep reference to prevent deallocation
+ (void)streamToken;
+}
+
+@end
diff --git a/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/main.m b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/main.m
new file mode 100644
index 000000000000..1bc39bfbad03
--- /dev/null
+++ b/src/AI/samples/AppleNative/EssentialsAISample/EssentialsAISample/main.m
@@ -0,0 +1,18 @@
+//
+// main.m
+// EssentialsAISample
+//
+// Created by Matthew Leibowitz on 2025/11/17.
+//
+
+#import
+#import "AppDelegate.h"
+
+int main(int argc, char * argv[]) {
+ NSString * appDelegateClassName;
+ @autoreleasepool {
+ // Setup code that might create autoreleased objects goes here.
+ appDelegateClassName = NSStringFromClass([AppDelegate class]);
+ }
+ return UIApplicationMain(argc, argv, nil, appDelegateClassName);
+}
diff --git a/src/AI/samples/Directory.Build.props b/src/AI/samples/Directory.Build.props
new file mode 100644
index 000000000000..41af9256608e
--- /dev/null
+++ b/src/AI/samples/Directory.Build.props
@@ -0,0 +1,10 @@
+
+
+ true
+ true
+ Maui
+ $(WarningsNotAsErrors);XC0022;XC0023
+ true
+
+
+
diff --git a/src/AI/samples/Directory.Build.targets b/src/AI/samples/Directory.Build.targets
new file mode 100644
index 000000000000..68be46514e92
--- /dev/null
+++ b/src/AI/samples/Directory.Build.targets
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/App.xaml b/src/AI/samples/Essentials.AI.Sample/App.xaml
new file mode 100644
index 000000000000..bcb92097e3ff
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/App.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/App.xaml.cs b/src/AI/samples/Essentials.AI.Sample/App.xaml.cs
new file mode 100644
index 000000000000..c82cafb92a64
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/App.xaml.cs
@@ -0,0 +1,14 @@
+namespace Maui.Controls.Sample;
+
+public partial class App : Application
+{
+ public App()
+ {
+ InitializeComponent();
+ }
+
+ protected override Window CreateWindow(IActivationState? activationState)
+ {
+ return new Window(new AppShell());
+ }
+}
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/AppShell.xaml b/src/AI/samples/Essentials.AI.Sample/AppShell.xaml
new file mode 100644
index 000000000000..036ee279df00
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/AppShell.xaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/AppShell.xaml.cs b/src/AI/samples/Essentials.AI.Sample/AppShell.xaml.cs
new file mode 100644
index 000000000000..d9d0dee6c438
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/AppShell.xaml.cs
@@ -0,0 +1,15 @@
+using Maui.Controls.Sample.Pages;
+
+namespace Maui.Controls.Sample;
+
+public partial class AppShell : Shell
+{
+ public AppShell()
+ {
+ InitializeComponent();
+
+ // Register routes for navigation
+ // Only TripPlanningPage is navigable - LandmarkTripView is a child component
+ Routing.RegisterRoute(nameof(TripPlanningPage), typeof(TripPlanningPage));
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Converters/InvertedBoolConverter.cs b/src/AI/samples/Essentials.AI.Sample/Converters/InvertedBoolConverter.cs
new file mode 100644
index 000000000000..3862bbd20460
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Converters/InvertedBoolConverter.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+
+namespace Maui.Controls.Sample.Converters;
+
+public class InvertedBoolConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is bool boolValue && !boolValue;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is bool boolValue && !boolValue;
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Converters/IsNotNullConverter.cs b/src/AI/samples/Essentials.AI.Sample/Converters/IsNotNullConverter.cs
new file mode 100644
index 000000000000..d81d737ce8f8
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Converters/IsNotNullConverter.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+
+namespace Maui.Controls.Sample.Converters;
+
+public class IsNotNullConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is not null;
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Converters/IsNotNullOrEmptyConverter.cs b/src/AI/samples/Essentials.AI.Sample/Converters/IsNotNullOrEmptyConverter.cs
new file mode 100644
index 000000000000..650da7bb383d
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Converters/IsNotNullOrEmptyConverter.cs
@@ -0,0 +1,16 @@
+using System.Globalization;
+
+namespace Maui.Controls.Sample.Converters;
+
+public class IsNotNullOrEmptyConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ return value is string str && !string.IsNullOrWhiteSpace(str);
+ }
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Directory.Build.targets b/src/AI/samples/Essentials.AI.Sample/Directory.Build.targets
new file mode 100644
index 000000000000..fcc6017c6249
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Directory.Build.targets
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj b/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj
new file mode 100644
index 000000000000..c27e64c3a63b
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Essentials.AI.Sample.csproj
@@ -0,0 +1,90 @@
+
+
+
+ $(MauiSamplePlatforms)
+ $(TargetFrameworks);$(MauiSamplePreviousPlatforms)
+ Maui.Essentials.AI.Sample
+ Microsoft.Maui.Essentials.AI.Sample
+ Exe
+ true
+ true
+ false
+ enable
+ enable
+ preview
+ SourceGen
+
+ maccatalyst-x64
+ maccatalyst-arm64
+ 2727d4aa-a3a5-484b-9447-91604761972b
+
+
+
+ Essentials AI
+ com.microsoft.maui.essentials.ai
+ 1.0
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $(DefineConstants);ENABLE_OPENAI_CLIENT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $([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/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs b/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs
new file mode 100644
index 000000000000..f95bc153fe11
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/MauiProgram.cs
@@ -0,0 +1,104 @@
+using System.Reflection;
+using Maui.Controls.Sample.Pages;
+using Maui.Controls.Sample.Services;
+using Maui.Controls.Sample.ViewModels;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Maui.Essentials.AI;
+#if ENABLE_OPENAI_CLIENT
+using System.ClientModel;
+using OpenAI;
+using OpenAI.Chat;
+#endif
+
+namespace Maui.Controls.Sample;
+
+public static class MauiProgram
+{
+ public static bool UseCloudAI = false;
+
+ public static MauiApp CreateMauiApp()
+ {
+ var builder = MauiApp.CreateBuilder();
+
+ builder.Configuration
+ .AddJsonStream(GetUserSecretsStream() ?? throw new InvalidOperationException("User secrets file not found as embedded resource."));
+
+ builder.UseMauiApp();
+
+#if IOS || ANDROID || MACCATALYST
+ builder.UseMauiMaps();
+#endif
+
+ builder.ConfigureFonts(fonts =>
+ {
+ fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
+ fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
+ });
+
+ // Register AI
+ #if ENABLE_OPENAI_CLIENT
+ if (UseCloudAI)
+ {
+ var aiSection = builder.Configuration.GetSection("AI");
+ var client = new ChatClient(
+ credential: new ApiKeyCredential(aiSection["ApiKey"] ?? throw new InvalidOperationException("API Key not found in user secrets.")),
+ model: aiSection["DeploymentName"] ?? throw new InvalidOperationException("Deployment Name not found in user secrets."),
+ options: new OpenAIClientOptions()
+ {
+ Endpoint = new(aiSection["Endpoint"] ?? throw new InvalidOperationException("Endpoint not found in user secrets.")),
+ });
+ var ichatClient = client.AsIChatClient();
+
+ builder.Services.AddSingleton(provider =>
+ {
+ var lf = provider.GetRequiredService();
+ var realClient = ichatClient
+ .AsBuilder()
+ .UseLogging(lf)
+ .UseFunctionInvocation()
+ .Build();
+ return realClient;
+ });
+ }
+ else
+ #endif
+ {
+ builder.Services.AddPlatformChatClient();
+ }
+
+ // Register Pages
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ // Register ViewModels
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+
+ // Register Services
+ builder.Services.AddSingleton(sp => LandmarkDataService.Instance);
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddHttpClient();
+
+ // Configure Logging
+ builder.Services.AddLogging();
+ builder.Logging.AddDebug();
+ builder.Logging.AddConsole();
+#if DEBUG
+ builder.Logging.SetMinimumLevel(LogLevel.Debug);
+#else
+ builder.Logging.SetMinimumLevel(LogLevel.Information);
+#endif
+
+ return builder.Build();
+ }
+
+ private static Stream? GetUserSecretsStream()
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var stream = assembly.GetManifestResourceStream("Maui.Essentials.AI.Sample.secrets.json");
+ return stream;
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Models/Itinerary.cs b/src/AI/samples/Essentials.AI.Sample/Models/Itinerary.cs
new file mode 100644
index 000000000000..d88be040c450
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Models/Itinerary.cs
@@ -0,0 +1,101 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+
+namespace Maui.Controls.Sample.Models;
+
+[DisplayName("Itinerary")]
+[Description("A travel itinerary with days and activities.")]
+public record Itinerary
+{
+ [Description("An exciting name for the trip.")]
+ public required string Title { get; init; }
+
+ public required string DestinationName { get; init; }
+
+ public required string Description { get; init; }
+
+ [Description("An explanation of how the itinerary meets the person's special requests.")]
+ public required string Rationale { get; init; }
+
+ [Description("A list of day-by-day plans.")]
+ [Length(3, 3)]
+ public required List Days { get; init; }
+
+ public static Itinerary GetExampleTripToJapan() =>
+ new()
+ {
+ Title = "Onsen Trip to Japan",
+ DestinationName = "Mt. Fuji",
+ Description = "Sushi, hot springs, and ryokan with a toddler!",
+ Rationale =
+ """
+ You are traveling with a child, so climbing Mt. Fuji is probably not an option,
+ but there is lots to do around Kawaguchiko Lake, including Fujikyu.
+ I recommend staying in a ryokan because you love hotsprings.
+ """,
+ Days = [
+ new DayPlan
+ {
+ Title = "Sushi and Shopping Near Kawaguchiko",
+ Subtitle = "Spend your final day enjoying sushi and souvenir shopping.",
+ Destination = "Kawaguchiko Lake",
+ Activities = [
+ new Activity
+ {
+ Type = ActivityKind.FoodAndDining,
+ Title = "The Restaurant serving Sushi",
+ Description = "Visit an authentic sushi restaurant for lunch."
+ },
+ new Activity
+ {
+ Type = ActivityKind.Shopping,
+ Title = "The Plaza",
+ Description = "Enjoy souvenir shopping at various shops."
+ },
+ new Activity
+ {
+ Type = ActivityKind.Sightseeing,
+ Title = "The Beautiful Cherry Blossom Park",
+ Description = "Admire the beautiful cherry blossom trees in the park."
+ },
+ new Activity
+ {
+ Type = ActivityKind.HotelAndLodging,
+ Title = "The Hotel",
+ Description = "Spend one final evening in the hotspring before heading home."
+ }]
+ }]
+ };
+}
+
+[DisplayName("DayPlan")]
+public record DayPlan
+{
+ [Description("A unique and exciting title for this day plan.")]
+ public required string Title { get; init; }
+
+ public required string Subtitle { get; init; }
+
+ public required string Destination { get; init; }
+
+ [Length(3, 3)]
+ public required List Activities { get; init; }
+}
+
+[DisplayName("Activity")]
+public record Activity
+{
+ public required ActivityKind Type { get; init; }
+
+ public required string Title { get; init; }
+
+ public required string Description { get; init; }
+}
+
+public enum ActivityKind
+{
+ Sightseeing,
+ FoodAndDining,
+ Shopping,
+ HotelAndLodging
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs b/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs
new file mode 100644
index 000000000000..3d7aafd3b114
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Models/Landmark.cs
@@ -0,0 +1,42 @@
+using System.Text.Json.Serialization;
+
+namespace Maui.Controls.Sample.Models;
+
+public record Landmark
+{
+ [JsonPropertyName("id")]
+ public int Id { get; init; }
+
+ [JsonPropertyName("name")]
+ public required string Name { get; init; }
+
+ [JsonPropertyName("continent")]
+ public required string Continent { get; init; }
+
+ [JsonPropertyName("description")]
+ public required string Description { get; init; }
+
+ [JsonPropertyName("shortDescription")]
+ public required string ShortDescription { get; init; }
+
+ [JsonPropertyName("latitude")]
+ public double Latitude { get; init; }
+
+ [JsonPropertyName("longitude")]
+ public double Longitude { get; init; }
+
+ [JsonPropertyName("span")]
+ public double Span { get; init; }
+
+ [JsonPropertyName("placeID")]
+ public string? PlaceId { get; init; }
+
+ [JsonIgnore]
+ public string BackgroundImageName => $"{Id}";
+
+ [JsonIgnore]
+ public string ThumbnailImageName => $"{Id}_thumb";
+
+ [JsonIgnore]
+ public Location Location => new(Latitude, Longitude);
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Models/Weather.cs b/src/AI/samples/Essentials.AI.Sample/Models/Weather.cs
new file mode 100644
index 000000000000..a2c2c8646a07
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Models/Weather.cs
@@ -0,0 +1,86 @@
+using System.Text.Json.Serialization;
+
+namespace Maui.Controls.Sample.Models;
+
+public record WeatherForecast
+{
+ [JsonPropertyName("latitude")]
+ public double Latitude { get; init; }
+
+ [JsonPropertyName("longitude")]
+ public double Longitude { get; init; }
+
+ [JsonPropertyName("daily")]
+ public DailyWeather Daily { get; init; } = new();
+}
+
+public record DailyWeather
+{
+ [JsonPropertyName("time")]
+ public List Time { get; init; } = [];
+
+ [JsonPropertyName("temperature_2m_mean")]
+ public List TemperatureMean { get; init; } = [];
+
+ [JsonPropertyName("weather_code")]
+ public List WeatherCode { get; init; } = [];
+}
+
+public static class WeatherCodeExtensions
+{
+ public static string GetWeatherEmoji(int code)
+ {
+ return code switch
+ {
+ 0 => "☀️",
+ 1 or 2 => "🌤️",
+ 3 => "☁️",
+ 45 or 48 => "🌫️",
+ 51 or 53 or 55 or 56 or 57 => "🌧️",
+ 61 or 63 or 65 => "🌧️",
+ 66 or 67 => "🌧️",
+ 71 or 73 or 75 or 77 => "❄️",
+ 80 or 81 or 82 => "🌧️",
+ 85 or 86 => "❄️",
+ 95 => "⛈️",
+ 96 or 99 => "⛈️",
+ _ => "☁️"
+ };
+ }
+
+ public static string GetWeatherDescription(int code)
+ {
+ return code switch
+ {
+ 0 => "Clear sky",
+ 1 => "Mainly clear",
+ 2 => "Partly cloudy",
+ 3 => "Overcast",
+ 45 => "Fog",
+ 48 => "Depositing rime fog",
+ 51 => "Light drizzle",
+ 53 => "Moderate drizzle",
+ 55 => "Dense drizzle",
+ 56 => "Light freezing drizzle",
+ 57 => "Dense freezing drizzle",
+ 61 => "Slight rain",
+ 63 => "Moderate rain",
+ 65 => "Heavy rain",
+ 66 => "Light freezing rain",
+ 67 => "Heavy freezing rain",
+ 71 => "Slight snow",
+ 73 => "Moderate snow",
+ 75 => "Heavy snow",
+ 77 => "Snow grains",
+ 80 => "Slight rain showers",
+ 81 => "Moderate rain showers",
+ 82 => "Violent rain showers",
+ 85 => "Slight snow showers",
+ 86 => "Heavy snow showers",
+ 95 => "Thunderstorm",
+ 96 => "Thunderstorm with slight hail",
+ 99 => "Thunderstorm with heavy hail",
+ _ => "Unknown"
+ };
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml
new file mode 100644
index 000000000000..6b5c624b5604
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml.cs
new file mode 100644
index 000000000000..71fb097ad41e
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Pages/LandmarksPage.xaml.cs
@@ -0,0 +1,26 @@
+using Maui.Controls.Sample.Models;
+using Maui.Controls.Sample.ViewModels;
+
+namespace Maui.Controls.Sample.Pages;
+
+public partial class LandmarksPage : ContentPage
+{
+ public LandmarksPage(LandmarksViewModel viewModel)
+ {
+ InitializeComponent();
+
+ BindingContext = viewModel;
+
+ Loaded += async (_, _) => await viewModel.InitializeAsync();
+ }
+
+ private async void OnLandmarkTapped(object? sender, Landmark landmark)
+ {
+ var parameters = new Dictionary
+ {
+ { "Landmark", landmark }
+ };
+
+ await Shell.Current.GoToAsync(nameof(TripPlanningPage), parameters);
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Pages/TripPlanningPage.xaml b/src/AI/samples/Essentials.AI.Sample/Pages/TripPlanningPage.xaml
new file mode 100644
index 000000000000..1ce3711ec370
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Pages/TripPlanningPage.xaml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Pages/TripPlanningPage.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Pages/TripPlanningPage.xaml.cs
new file mode 100644
index 000000000000..4bbfe575d52b
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Pages/TripPlanningPage.xaml.cs
@@ -0,0 +1,20 @@
+using Maui.Controls.Sample.ViewModels;
+
+namespace Maui.Controls.Sample.Pages;
+
+public partial class TripPlanningPage : ContentPage
+{
+ public TripPlanningPage(TripPlanningViewModel viewModel)
+ {
+ InitializeComponent();
+
+ BindingContext = viewModel;
+
+ Loaded += async (_, _) => await viewModel.InitializeAsync();
+ }
+
+ private async void OnBackButtonClicked(object? sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("..");
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Android/AndroidManifest.xml b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 000000000000..e9937ad77d51
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Android/MainActivity.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/MainActivity.cs
new file mode 100644
index 000000000000..cebbb65a1fcf
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/MainActivity.cs
@@ -0,0 +1,10 @@
+using Android.App;
+using Android.Content.PM;
+using Android.OS;
+
+namespace Maui.Controls.Sample;
+
+[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/src/AI/samples/Essentials.AI.Sample/Platforms/Android/MainApplication.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/MainApplication.cs
new file mode 100644
index 000000000000..7ca70bdfbefe
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/MainApplication.cs
@@ -0,0 +1,15 @@
+using Android.App;
+using Android.Runtime;
+
+namespace Maui.Controls.Sample;
+
+[Application]
+public class MainApplication : MauiApplication
+{
+ public MainApplication(IntPtr handle, JniHandleOwnership ownership)
+ : base(handle, ownership)
+ {
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Android/Resources/values/colors.xml b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/Resources/values/colors.xml
new file mode 100644
index 000000000000..c04d7492abf8
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Android/Resources/values/colors.xml
@@ -0,0 +1,6 @@
+
+
+ #512BD4
+ #2B0B98
+ #2B0B98
+
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/AppDelegate.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/AppDelegate.cs
new file mode 100644
index 000000000000..70930ecd7044
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace Maui.Controls.Sample;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Entitlements.plist b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Entitlements.plist
new file mode 100644
index 000000000000..de4adc94a9c9
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Entitlements.plist
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ com.apple.security.app-sandbox
+
+
+ com.apple.security.network.client
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Info.plist b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 000000000000..72689771518a
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ UIDeviceFamily
+
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Program.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Program.cs
new file mode 100644
index 000000000000..d96ccc991875
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/MacCatalyst/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace Maui.Controls.Sample;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/App.xaml b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/App.xaml
new file mode 100644
index 000000000000..bd70e8ea2553
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/App.xaml
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/App.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/App.xaml.cs
new file mode 100644
index 000000000000..fee8c0b3dd3e
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,24 @@
+using Microsoft.UI.Xaml;
+
+// To learn more about WinUI, the WinUI project structure,
+// and more about our project templates, see: http://aka.ms/winui-project-info.
+
+namespace Maui.Controls.Sample.WinUI;
+
+///
+/// Provides application-specific behavior to supplement the default Application class.
+///
+public partial class App : MauiWinUIApplication
+{
+ ///
+ /// Initializes the singleton application object. This is the first line of authored code
+ /// executed, and as such is the logical equivalent of main() or WinMain().
+ ///
+ public App()
+ {
+ this.InitializeComponent();
+ }
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/Package.appxmanifest b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 000000000000..b5b1cc504dbe
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+ $placeholder$
+ User Name
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/app.manifest b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/app.manifest
new file mode 100644
index 000000000000..1a5a8fb34b88
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/Windows/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/AppDelegate.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/AppDelegate.cs
new file mode 100644
index 000000000000..70930ecd7044
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/AppDelegate.cs
@@ -0,0 +1,9 @@
+using Foundation;
+
+namespace Maui.Controls.Sample;
+
+[Register("AppDelegate")]
+public class AppDelegate : MauiUIApplicationDelegate
+{
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Info.plist b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Info.plist
new file mode 100644
index 000000000000..0004a4fdee5d
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/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/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Program.cs b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Program.cs
new file mode 100644
index 000000000000..d96ccc991875
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Program.cs
@@ -0,0 +1,15 @@
+using ObjCRuntime;
+using UIKit;
+
+namespace Maui.Controls.Sample;
+
+public class Program
+{
+ // This is the main entry point of the application.
+ static void Main(string[] args)
+ {
+ // if you want to use a different Application Delegate class from "AppDelegate"
+ // you can specify it here.
+ UIApplication.Main(args, null, typeof(AppDelegate));
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
new file mode 100644
index 000000000000..24ab3b4334cb
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Platforms/iOS/Resources/PrivacyInfo.xcprivacy
@@ -0,0 +1,51 @@
+
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+
+ C617.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategorySystemBootTime
+ NSPrivacyAccessedAPITypeReasons
+
+ 35F9.1
+
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryDiskSpace
+ NSPrivacyAccessedAPITypeReasons
+
+ E174.1
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Properties/launchSettings.json b/src/AI/samples/Essentials.AI.Sample/Properties/launchSettings.json
new file mode 100644
index 000000000000..4f857936f4f5
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+ "profiles": {
+ "Windows Machine": {
+ "commandName": "Project",
+ "nativeDebugging": false
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/README.md b/src/AI/samples/Essentials.AI.Sample/README.md
new file mode 100644
index 000000000000..ab409275ee7a
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/README.md
@@ -0,0 +1,93 @@
+# Essentials.AI.Sample - Trip Planner
+
+AI-powered travel itinerary generator using Microsoft.Extensions.AI in .NET MAUI.
+
+## Overview
+
+This sample demonstrates how to integrate Large Language Models (LLMs) into a .NET MAUI application using Microsoft.Extensions.AI. The app generates personalized multi-day travel itineraries for famous landmarks worldwide, featuring:
+
+- **Streaming AI Responses**: Real-time itinerary generation with incremental updates
+- **Structured JSON Output**: Uses JSON schema to ensure consistent, typed responses
+- **AI-Powered Tagging**: Automatic keyword extraction from landmark descriptions
+- **Cross-Platform**: Runs on iOS, Android, Windows, and macOS
+
+## How It Works
+
+1. **Chat Client Setup**: An AI chat client is registered as an `IChatClient` singleton in dependency injection
+2. **Landmark Selection**: Users browse landmarks organized by continent and select one for trip planning
+3. **Tag Generation**: The `TaggingService` extracts relevant tags from landmark descriptions using AI with JSON response format
+4. **Itinerary Generation**: The `ItineraryService` streams a structured 3-day itinerary using:
+ - System instructions that define the AI's role and constraints
+ - JSON schema validation to ensure properly formatted responses with required fields (title, days, activities, etc.)
+ - Real-time streaming updates displayed progressively in the UI
+5. **Display**: Generated itineraries show daily activities including sightseeing, dining, and lodging recommendations
+
+### Key AI Patterns
+
+- **Structured Output**: Uses `ChatResponseFormat.ForJsonSchema()` to enforce response structure matching C# record types
+- **Streaming**: Implements `IAsyncEnumerable` to process AI responses incrementally as they arrive
+- **Schema Generation**: Uses `AIJsonUtilities.CreateJsonSchema()` with custom transforms to add enum constraints and property ordering
+- **Partial Deserialization**: Deserializes incomplete JSON during streaming to show progressive updates
+
+## Project Structure
+
+### Core Files
+
+- **`MauiProgram`**: Configures DI container and registers `IChatClient` and services
+- **`App`**: Application entry point, creates main window with `AppShell`
+- **`AppShell`**: Shell navigation structure, defines `LandmarksPage` as the initial route
+
+### Pages
+
+- **`Pages/LandmarksPage`**: Displays landmarks organized by continent with featured item
+- **`Pages/TripPlanningPage`**: Shows landmark details, AI-generated tags, and itinerary generation UI
+
+### ViewModels
+
+- **`ViewModels/LandmarksViewModel`**: Manages landmark data loading and grouping by continent
+- **`ViewModels/TripPlanningViewModel`**: Orchestrates itinerary generation, tag display, and loading states using `[QueryProperty]` for navigation parameters
+
+### Models
+
+- **`Models/Landmark`**: Represents a travel destination with coordinates, description, and metadata
+- **`Models/Itinerary`**: Structured itinerary with title, days, and activities; includes `ToJsonSchema()` method for AI response validation
+
+### Services
+
+- **`Services/ItineraryService`**: Streams AI-generated itineraries using `IChatClient.GetStreamingResponseAsync()` with JSON schema constraints
+- **`Services/TaggingService`**: Generates descriptive tags from text using AI with JSON response format
+- **`Services/LandmarkDataService`**: Singleton service that loads and provides access to landmark data from `landmarkData.json`
+- **`Services/FindPointsOfInterestTool`**: Mock tool for AI function calling (demonstrates tool integration pattern, currently returns placeholder data)
+- **`Services/StreamingJsonDeserializer`**: Utility for deserializing incomplete JSON during streaming
+
+### Views
+
+**Landmarks:**
+- **`Views/Landmarks/LandmarkFeaturedItemView`**: Large featured landmark card display
+- **`Views/Landmarks/LandmarkHorizontalListView`**: Horizontal scrolling list of landmarks
+- **`Views/Landmarks/LandmarkListItemView`**: Individual landmark item template
+
+**Itinerary:**
+- **`Views/Itinerary/LandmarkTripView`**: Container view for trip planning interface
+- **`Views/Itinerary/LandmarkDescriptionView`**: Displays landmark details and AI-generated tags
+- **`Views/Itinerary/ItineraryPlanningView`**: Itinerary generation trigger and loading state
+- **`Views/Itinerary/ItineraryView`**: Complete itinerary display with title and days
+- **`Views/Itinerary/DayView`**: Single day plan view with activities
+- **`Views/Itinerary/ActivityListView`**: List of activities for a day
+- **`Views/Itinerary/MessageView`**: Error message display
+
+### Converters
+
+- **`Converters/InvertedBoolConverter`**: XAML converter to invert boolean values
+- **`Converters/IsNotNullConverter`**: XAML converter to check if value is not null
+- **`Converters/IsNotNullOrEmptyConverter`**: XAML converter to check if collection is not empty
+
+### Configuration
+
+- **`Essentials.AI.Sampleproj`**: Project file with Microsoft.Extensions.AI packages
+- **`Directory.Build.targets`**: Build configuration
+
+## Dependencies
+
+- **Microsoft.Extensions.AI**: Core AI abstractions and chat client interfaces
+- **CommunityToolkit.Mvvm**: MVVM helpers for data binding and commands
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/AppIcon/appicon.svg b/src/AI/samples/Essentials.AI.Sample/Resources/AppIcon/appicon.svg
new file mode 100644
index 000000000000..9d63b6513a1c
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/AppIcon/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/AppIcon/appiconfg.svg b/src/AI/samples/Essentials.AI.Sample/Resources/AppIcon/appiconfg.svg
new file mode 100644
index 000000000000..21dfb25f187b
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/AppIcon/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Fonts/OpenSans-Regular.ttf b/src/AI/samples/Essentials.AI.Sample/Resources/Fonts/OpenSans-Regular.ttf
new file mode 100644
index 000000000000..63ba3efc179b
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Fonts/OpenSans-Regular.ttf differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Fonts/OpenSans-Semibold.ttf b/src/AI/samples/Essentials.AI.Sample/Resources/Fonts/OpenSans-Semibold.ttf
new file mode 100644
index 000000000000..774cb9bb310b
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Fonts/OpenSans-Semibold.ttf differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Images/dotnet_bot.png b/src/AI/samples/Essentials.AI.Sample/Resources/Images/dotnet_bot.png
new file mode 100644
index 000000000000..054167e59747
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Images/dotnet_bot.png differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/AboutAssets.txt b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/AboutAssets.txt
new file mode 100644
index 000000000000..89dc758d6e0d
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/AboutAssets.txt
@@ -0,0 +1,15 @@
+Any raw assets you want to be deployed with your application can be placed in
+this directory (and child directories). Deployment of the asset to your application
+is automatically handled by the following `MauiAsset` Build Action within your `.csproj`.
+
+
+
+These files will be deployed with your package and will be accessible using Essentials:
+
+ async Task LoadMauiAsset()
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("AboutAssets.txt");
+ using var reader = new StreamReader(stream);
+
+ var contents = reader.ReadToEnd();
+ }
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1001.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1001.jpg
new file mode 100644
index 000000000000..ff63af08bec4
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1001.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1002.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1002.jpg
new file mode 100644
index 000000000000..2446e92a1f9b
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1002.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1003.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1003.jpg
new file mode 100644
index 000000000000..82121c8cf678
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1003.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1004.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1004.jpg
new file mode 100644
index 000000000000..e56bc7582ad8
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1004.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1005.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1005.jpg
new file mode 100644
index 000000000000..1efa74bd53a5
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1005.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1006.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1006.jpg
new file mode 100644
index 000000000000..9100f361c084
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1006.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1007.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1007.jpg
new file mode 100644
index 000000000000..47bd2c822c8a
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1007.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1008.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1008.jpg
new file mode 100644
index 000000000000..e6b22fc2c68f
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1008.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1009.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1009.jpg
new file mode 100644
index 000000000000..2d93e94b1943
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1009.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1010.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1010.jpg
new file mode 100644
index 000000000000..ff25d95f4b47
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1010.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1011.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1011.jpg
new file mode 100644
index 000000000000..43cc8b03cd2f
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1011.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1012.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1012.jpg
new file mode 100644
index 000000000000..79e172af3c95
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1012.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1014.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1014.jpg
new file mode 100644
index 000000000000..963098d9bd41
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1014.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1015.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1015.jpg
new file mode 100644
index 000000000000..4aecc64251f4
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1015.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1016.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1016.jpg
new file mode 100644
index 000000000000..3bc356c9ac7b
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1016.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1017.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1017.jpg
new file mode 100644
index 000000000000..5077a4f43b95
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1017.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1018.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1018.jpg
new file mode 100644
index 000000000000..6755635f1805
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1018.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1019.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1019.jpg
new file mode 100644
index 000000000000..4542228ae996
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1019.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1020.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1020.jpg
new file mode 100644
index 000000000000..b2f4f5ddcc3c
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1020.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1021.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1021.jpg
new file mode 100644
index 000000000000..c9544e06b5c8
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Backgrounds/1021.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1001_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1001_thumb.jpg
new file mode 100644
index 000000000000..72d869b25e3c
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1001_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1002_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1002_thumb.jpg
new file mode 100644
index 000000000000..85a8f1f0d529
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1002_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1003_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1003_thumb.jpg
new file mode 100644
index 000000000000..403190c922bc
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1003_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1004_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1004_thumb.jpg
new file mode 100644
index 000000000000..34ca80f8a46b
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1004_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1005_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1005_thumb.jpg
new file mode 100644
index 000000000000..1368e60d20ed
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1005_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1006_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1006_thumb.jpg
new file mode 100644
index 000000000000..53a8fade6407
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1006_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1007_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1007_thumb.jpg
new file mode 100644
index 000000000000..c4f31826b935
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1007_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1008_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1008_thumb.jpg
new file mode 100644
index 000000000000..2a2847896819
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1008_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1009_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1009_thumb.jpg
new file mode 100644
index 000000000000..475d182f067f
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1009_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1010_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1010_thumb.jpg
new file mode 100644
index 000000000000..1e74432e16a8
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1010_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1011_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1011_thumb.jpg
new file mode 100644
index 000000000000..6c4d02ca5cef
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1011_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1012_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1012_thumb.jpg
new file mode 100644
index 000000000000..1c50f3d6c96a
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1012_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1014_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1014_thumb.jpg
new file mode 100644
index 000000000000..eb17bcb430a0
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1014_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1015_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1015_thumb.jpg
new file mode 100644
index 000000000000..880dea3f0666
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1015_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1016_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1016_thumb.jpg
new file mode 100644
index 000000000000..e77757bfd17b
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1016_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1017_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1017_thumb.jpg
new file mode 100644
index 000000000000..f457c251e4c3
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1017_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1018_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1018_thumb.jpg
new file mode 100644
index 000000000000..9e204f823b6e
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1018_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1019_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1019_thumb.jpg
new file mode 100644
index 000000000000..18399c92c3ae
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1019_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1020_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1020_thumb.jpg
new file mode 100644
index 000000000000..e4cc55d85877
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1020_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1021_thumb.jpg b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1021_thumb.jpg
new file mode 100644
index 000000000000..1893301c13ca
Binary files /dev/null and b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/Images/Thumbnails/1021_thumb.jpg differ
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Raw/landmarkData.json b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/landmarkData.json
new file mode 100644
index 000000000000..b582a1311a95
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/Raw/landmarkData.json
@@ -0,0 +1,222 @@
+[
+ {
+ "name": "Sahara Desert",
+ "continent": "Africa",
+ "id": 1001,
+ "placeID": "IC6C65CA81B4B2772",
+ "longitude": 10.33569,
+ "latitude": 23.90013,
+ "span": 40.0,
+ "description": "The sprawling Sahara Desert spans more than 3.5 million square miles and is the largest hot desert in the world. Covering much of northern Africa, it reaches three major bodies of water: the Atlantic Ocean in the west, the Red Sea in the east, and the Mediterranean Sea to the north. To the south is the Sahei region. The Sahara’s harsh environment is characterized by vast sand dunes, arid plains, and rocky plateaus. The tallest sand dune is located in the Erg Chebbi region of southeastern Morocco and reaches a height of roughly 330 feet. The desert plays an important role in shaping climate patterns and ecological systems.\n\nFormed over the course of millions of years and dating back to the Precambrian era, the Sahara was once a lush, verdant landscape with lakes and rivers. Over many years, wind erosion has sculpted towering cliffs, rock arches, deep canyons and many striking and picturesque landforms. Beneath a surface composed of gravel plains and sand seas lies vast underground aquifers that store ancient water, hidden remnants of a time when the Sahara was a much more hospitable place.\n\nWhile the Sahara experiences extreme temperatures and has scarce rainfall, it supports a variety of plant and animal life that have adapted to the harsh conditions. The sparse vegetation includes drought-tolerant varieties such as desert grasses such as Lovegrass that can store water in their roots and help prevent soil erosion. Other vegetation includes Acacia trees, date palms, and olive trees. Animal life has also adapted to be able to endure the extreme heat and extended periods of time with little or no water. Fennec foxes, camels, addax, and scorpions are some examples of the species found in the Sahara. Many animals have made nocturnal adaptations in order to remain hidden during the high daytime temperatures. Many migratory birds pass through the region and take advantage of seasonal water sources and oases.",
+ "shortDescription": "Covering much of northern Africa, it reaches three major bodies of water: the Atlantic Ocean in the west, the Red Sea in the east, and the Mediterranean Sea to the north."
+ },
+ {
+ "name": "Serengeti",
+ "continent": "Africa",
+ "id": 1002,
+ "placeID": "IB3A0184A4D301279",
+ "longitude": 34.88159,
+ "latitude": -2.45469,
+ "span": 10.0,
+ "description": "Located in northern Tanzania and southwestern Kenya, the Serengeti is a mix of riparian forests, acacia woodlands, and grasslands that covers nearly 12,000 square miles. With open plains that stretch seemingly forever, it creates a breathtaking backdrop for one of the most spectacular wildlife events on Earth: the awe-inspiring Great Migration.\n\nVolcano activity from the nearby Ngorongoro Highlands shaped much of the region, depositing nutrient-rich soil as a result of past eruptions. The Serengeti sits atop Precambrian rock formations and supports vast herds of herbivores with abundant grasses. The Mara and Grumeti rivers carve their way through he plains, providing crucial water supplies to sustain wildlife year-round.\n\nThe Serengeti is home to one of Africa’s highest concentrations of large mammal species, including elephants, giraffes, hyenas, lions, and zebras. Every year, more than a million wildebeest make a circular migration across the Serengeti Plains, following seasonal rains. Their grazing and trampling of grass allow new grasses to grow, while their wast helps fertilize the soil.",
+ "shortDescription": "Located in northern Tanzania and southwestern Kenya, the Serengeti is a mix of riparian forests, acacia woodlands, and grasslands that covers nearly 12,000 square miles."
+ },
+ {
+ "name": "Deadvlei",
+ "continent": "Africa",
+ "id": 1003,
+ "placeID": "IBD2966F32E73D261",
+ "longitude": 15.29429,
+ "latitude": -24.7629,
+ "span": 10.0,
+ "description": "An otherworldly landscape of contrasting bleached white claypan ground, blackened camel thorn trees, and fiery red-orange dunes, Deadvlei is located in Namibia’s Namib-Naukluft National Park. Over 900 years ago, Deadvlei was a thriving marshland, but dried up as shifting sand dunes cut off its water supply. Despite it’s remote location, Deadvlei has become a renown tourist attraction, especially for photographers, enticing visitors from around the world to witness it’s unique beauty.\n\nHome to some of highest dunes in the world — some reaching up to 1100 feet — are sculpted into graceful ever-changing shapes. The vibrant hue of the dunes is a result of the iron oxide content in the sand and shift with the sunlight.\n\nDue to extreme arid conditions, the dead camel trees don’t decompose, leaving behind ghostly reminders of a vibrant ecosystem dating back a millennia. Even with the harsh environment, some plant species such as nara melons and salsola have adapted and survive off the morning mist and rare rainfall.",
+ "shortDescription": "An otherworldly landscape of contrasting bleached white claypan ground, blackened camel thorn trees, and fiery red-orange dunes, Deadvlei is located in Namibia’s Namib-Naukluft National Park."
+ },
+ {
+ "name": "Grand Canyon",
+ "continent": "North America",
+ "id": 1004,
+ "placeID": "I55488B3D1D9B2D4B",
+ "longitude": -113.16096,
+ "latitude": 36.21904,
+ "span": 10.0,
+ "description": "When you look down from the rim of the Grand Canyon, you are looking at a visual story of Earth’s ancient past. Carved over millions of years by the Colorado River, the canyon stretches 277 miles in length, spans widths up to 18 miles, and reaches depths of over a mile (6,093 feet). The vibrant house of the canyon, ranging from deep reds and oranges to subtle shades of purple, it is an iconic natural wonder.\n\nThe geological story that the canyon tells spans nearly two billion years. Vishnu Schist at the bottom of the Inner Gorge is estimated to be 1.7 billion years old. The much younger Kaibab Limestone formation on the rim is the canyon’s top most rock layer, dating back a mere 270 million years. Between these two extremes, wind and water erosion have sculpted stunning cliffs, mesas, and buttes to create an intricate landscape of canyons within canyons. Every year, the seasonal variations in the Colorado River’s flow continue to carve the canyons deeper and deeper.\n\nJust as the geology story shifts with the altitude change, the plant and animal diversity changes dramatically. Flora ranges from desert species such as cacti and junipers to high-elevation forests of spruce fir or ponderosa pine, groundsels, yarrow, and lupine. Bighorn sheep, mule deer, and the elusive mountain lion are just some of the wildlife species that make their home in this ecological treasure.",
+ "shortDescription": "Carved over millions of years by the Colorado River, the canyon stretches 277 miles in length, spans widths up to 18 miles, and reaches depths of over a mile (6,093 feet)."
+ },
+ {
+ "name": "Niagara Falls",
+ "continent": "North America",
+ "id": 1005,
+ "placeID": "I433E22BD30C61C40",
+ "longitude": -79.07401,
+ "latitude": 43.07792,
+ "span": 4.0,
+ "description": "Niagara Falls is comprised of three separate falls, Horseshoe Falls, American Falls, and Bridal Veil Falls. It lies at the border between Canada and the United States. The millions of gallons of water that plunge over its edge originates from Lake Erie in the Great Lakes. The flow of water produces a roaring cascade, fills the air with mist, and creates vibrant rainbows. It is one of the most famous and powerful waterfalls in the world.\n\nThe Falls began to form at the end of the last Ice Age, taking more than 12,000 years to fully develop. The process continues today as the flow of water continues to erode sedimentary layers of rock to shape and reshape the landscape. Preservation work has been done to preserve the Falls, and the volume of water has reduced due to diversion for hydroelectric power, which generates nearly 4.9 million kilowatts of power.\n\nA dynamic ecosystem exists around the Falls. Fish life thrives in the Niagara River, including species such as bass and sturgeon. Gulls, bald eagles, and peregrine falcons are some of the avian wildlife that can be seen flying above the Falls, while lush vegetation grows along the riverbanks.",
+ "shortDescription": "Niagara Falls is comprised of three separate falls, Horseshoe Falls, American Falls, and Bridal Veil Falls. It lies at the border between Canada and the United States. The millions of gallons of water that plunge over its edge originates from Lake Erie in the Great Lakes. The flow of water produces a roaring cascade, fills the air with mist, and creates vibrant rainbows."
+ },
+ {
+ "name": "Joshua Tree",
+ "continent": "North America",
+ "id": 1006,
+ "placeID": "I34674B3D3B032AA2",
+ "longitude": -115.80826,
+ "latitude": 33.88752,
+ "span": 10.0,
+ "description": "Spanning more than 1,200 square miles, Joshua Tree National Park is located in Southern California not far from Palm Springs. The park spans both the Mojave and Colorado deserts and is most known for the rugged rock formations and the spiky, twisted Joshua trees. The two deserts have distinct characteristics. On the western side of the park, the higher and cooler Mojave is a high desert ecosystem while the eastern side’s Colorado is a low desert climate.\n\nAncient volcanic activity, erosion, and tectonic fault movement shaped the park’s dramatic landscape. Surreal formations of massive granite boulders, rock hills (known as inselbergs), hidden canyons, and the unique Joshua trees create an otherworldly beauty.\n\nDespite the harsh desert climates, a variety of resilient wildlife and plant life can be found. Larger mammals such as coyote, desert bighorn sheep, mountain lion often burrow or stay in caves during the hot day time hours, becoming more active at night. Ancient bristlecone pines, creosote bush, and ephemeral spring wildflowers are some of the more than 1,000 species of plant found Joshua Tree.",
+ "shortDescription": "Spanning more than 1,200 square miles, Joshua Tree National Park is located in Southern California, not far from Palm Springs."
+ },
+ {
+ "name": "Rocky Mountains",
+ "continent": "North America",
+ "id": 1007,
+ "placeID": "IBD757C9B53C92D9E",
+ "longitude": -112.99872,
+ "latitude": 47.62596,
+ "span": 16.0,
+ "description": "Stretching 3,000 miles from northwest Canada to southwest United States, the Rocky Mountains are the setting for some of North America’s most stunning scenery. With soaring peaks, jagged summits, pastoral alpine meadows, dense forests, and crystal clear lakes, the Rockies are home to a wide range of flora and fauna. The mountain habitats support a wide range wildlife including wolves, elk, moose, bighorn sheep, grizzly bears, and wolverines. The variety of plants found in the Rocky Mountains is similarly diverse including over 900 species of wildflowers and conifers such as pine, spruce, and fir. Deciduous trees, known for their brilliant colorful autumn leaves contribute to some of the most biologically diverse habitats in the Rocky Mountain National Park. All of these ecological elements and waterways provide an important habitat for North America migratory birds.\n\nThe geological history of the Rocky Mountains dates back approximately 60 to 70 million years when several tectonic plates began shifting under the North American plate. The result was a long, broad belt of mountains running along western North America. The dramatic peaks and valleys we see today were further formed by tectonic activity and erosion by glaciers. The majority of the highest peaks are found in Colorado. Numerous public parks protect much of the mountain range, providing tourists plenty of year-round activities.",
+ "shortDescription": "Stretching 3,000 miles from northwest Canada to southwest United States, the Rocky Mountains are the setting for some of North America’s most stunning scenery."
+ },
+ {
+ "name": "Monument Valley",
+ "continent": "North America",
+ "id": 1008,
+ "placeID": "IAB1F0D2360FAAD29",
+ "longitude": -110.348,
+ "latitude": 36.874,
+ "span": 10.0,
+ "description": "Presenting a spectacular display of towering sandstone buttes, vibrant red rock formations, and expansive open plains, Monument Valley is an iconic landscape of the Colorado Plateau, located near the Arizona-Utah border. The stratified buttes rise dramatically from the valley floor reaching heights up to 1,000 feet. Formed by rivers, wind, and ice, the valley is comprised largely of siltstone and sand deposits. Softer material eroded away, leaving the massive sandstone buttes that remain today.\n\nVegetation in Monument Valley is sparse but beautiful. Contributing to an interesting palette, plants like purple sage complement the red rock formations with splashes of purple flowers and white or gray leaves. Rabbitbrush brings in yellow flowers and green leaves. Mojave yucca plants have fine hairs and a wax coating to trap moisture and reflect sunlight to help it survive.\n\nAnimal life is diverse, with large mammals like the mountain lions, coyotes, and jackrabbits. Reptiles include lizards such as the long-nose leopard lizard as well as iguanas and various snakes. With watchful eyes soaring above all of this are red-tailed hawks, tree sparrows, and more.",
+ "shortDescription": "Presenting a spectacular display of towering sandstone buttes, vibrant red rock formations, and expansive open plains, Monument Valley is an iconic landscape of the Colorado Plateau, located near the Arizona-Utah border."
+ },
+ {
+ "name": "Muir Woods",
+ "continent": "North America",
+ "id": 1009,
+ "placeID": "I907589547EB05261",
+ "longitude": -122.57482,
+ "latitude": 37.8922,
+ "span": 2.0,
+ "description": "Frequently shrouded in coastal marine layer fog from the Pacific Ocean, Muir Woods National Monument is an old-growth redwood forest. Located 12 miles north of San Francisco, covering 558 acres, and containing 6 miles of gorgeous hiking trails, Muir Woods is part of the Golden Gate National Recreation Area.\n\nThe moist environment promotes a rich community of interesting plants, organized into three layers. The lowest layer is the herbaceous layer and is full of shade-loving life. The next layer is the understory, where shrubs and tress such as the California bay and tan oak grow. Providing shelter and platforms for various tree-dwelling species is the topmost layer, or canopy.\n\nWithin the multilayered, dense habitat, it’s easy for wildlife to remain unseen, making the forest sometimes appear empty. Looks, however, are deceiving. Muir Wood hosts over 50 species of birds including spotted owls and pileated woodpeckers. Mammals range in size from the small American shrew mole to the black-tailed mule deer. Most mammals are nocturnal or burrowing animals, contributing to the sense of emptiness in the forest.",
+ "shortDescription": "Located 12 miles north of San Francisco, covering 558 acres, and containing 6 miles of gorgeous hiking trails, Muir Woods is part of the Golden Gate National Recreation Area."
+ },
+ {
+ "name": "Amazon Rainforest",
+ "continent": "South America",
+ "id": 1010,
+ "placeID": "I76A1045FB9294971",
+ "longitude": -62.80802,
+ "latitude": -3.50879,
+ "span": 30.0,
+ "description": "As a member of an elite club of natural landmarks that cover more than one percent of the planet’s surface, the Amazon rainforest covers approximately 2.3 million square miles. The majority of the rainforest is in Brazil, but it spills over into other neighboring countries including Peru, Bolivia, and Columbia. While it’s common to think of a rainforest as being sparsely populated, an estimated 30 million people live in the Amazon.\n\nWith the highest diversity of plant life on Earth, the Amazon may contain as many as 80,000 plant species. An astounding 75 percent of those species are endemic to the area, not being found anywhere else, including 16,000 trees species. Other unique species include giant Amazon water lily, rubber trees, and cacao trees.\n\nHundreds of animal species are also found in the rainforest. Notably, the Amazon is one of Earth’s last refuges for jaguars, harpy eagles, and pink river dolphins. Many other large animals live in the Amazon including cougars, the black caiman, and even the Caquetá titi monkey which purrs like a cat.",
+ "shortDescription": "As a member of an elite club of natural landmarks that cover more than one percent of the planet’s surface, the Amazon rainforest covers approximately 2.3 million square miles."
+ },
+ {
+ "name": "Lençóis Maranhenses",
+ "continent": "South America",
+ "id": 1011,
+ "placeID": "I292A37DAC754D6A0",
+ "longitude": -43.03345,
+ "latitude": -2.57812,
+ "span": 10.0,
+ "description": "Lençóis Maranhenses National Park covers approximately 380,000 acres on the northeastern coast of Brazil, including 43 miles of coastline along the Atlantic Ocean. The interior of the park is composed of rolling sand dunes that reach as high as 130 feet.\n\nDespite looking like a desert environment, the area receives about 47 inches of rain per year. During the rainy seasons, the spaces between the dune peaks fill with freshwater lagoons.\n\nWith desert-like but wet locale, a unique and diverse ecosystem has evolved. Vegetation adapted to coastal and freshwater environments such as Restinga and mangrove are found here. Four endangered species of animals reside in the park: the scarlet ibis, neotropical otter, oncilla, and West Indian manatee. The wolf fish is a unique species that burrows into wet mud during the dry season.",
+ "shortDescription": "Lençóis Maranhenses National Park covers approximately 380,000 acres on the northeastern coast of Brazil, including 43 miles of coastline along the Atlantic Ocean."
+ },
+ {
+ "name": "Uyuni Salt Flat",
+ "continent": "South America",
+ "id": 1012,
+ "placeID": "ID903C9A78EB0CAAD",
+ "longitude": -67.48914,
+ "latitude": -20.13378,
+ "span": 10.0,
+ "description": "At an elevation of nearly 12,000 feet above sea level near the crest of the Andes mountain range in southwestern Bolivia, the Salar de Uyuni is the world’s largest salt flat. Extraordinarily flat, with elevation variation no more than one meter over the nearly 4,000 square mile area, the Salar was formed by several prehistoric lakes evaporating over the last 40,000 years. The resulting salt crust is several meters thick and rich in lithium.\n\nDespite its prehistoric origin, modern technology has found an important use for the Uyuni. Because salt flats are large, stable surfaces, they are ideal for satellite calibration. Uyuni is especially well suited for this task due to the lack of industry, long low rain periods, and very clear dry air. During the rainy season, the flat turns into a shallow lake with a glass-like surface and becomes the world’s largest natural mirror.\n\nThe salt flat has few forms of plant life, which includes the giant cacti, Echinopsis pasacana and Echinopsis tarijensis, which tower up to 40 feet over the Uyuni flats. Other plants found in Uyuni include pitaya (or dragon fruit), quinoa plants, and queñua bushes.",
+ "shortDescription": "At an elevation of nearly 12,000 feet above sea level near the crest of the Andes mountain range in southwestern Bolivia, the Salar de Uyuni is the world’s largest salt flat."
+ },
+ {
+ "name": "White Cliffs of Dover",
+ "continent": "Europe",
+ "id": 1014,
+ "placeID": "I77B160572D5A2EB1",
+ "longitude": 1.36351,
+ "latitude": 51.13641,
+ "span": 4.0,
+ "description": "Standing guard over the narrowest part of the English Channel, the White Cliffs of Dover present a striking façade of chalk streaked with accents of black flint. Over millions of years, skeletons of tiny algae and other sea creatures settled into the white mud under the sea. After these deposits compacted to form chalk, a major mountain building event forced the undersea masses above sea level to form the cliffs.\n\nAbove the cliffs, a chalk grassland supports many species of wildflowers, butterflies, and birds. Orchids, rock samphire, and the unusual oxtongue broomrape are found here. In spring and autumn, the rare Adonis blue butterfly can be seen in the grasslands, as can the similar looking chalk hill blue. Many migratory birds use the cliffs as their first landing point after crossing the English Channel. Peregrine falcons and the skylark are some of the birds that make their homes along the cliffs.",
+ "shortDescription": "Standing guard over the narrowest part of the English Channel, the White Cliffs of Dover present a striking façade of chalk streaked with accents of black flint."
+ },
+ {
+ "name": "Alps",
+ "continent": "Europe",
+ "id": 1015,
+ "placeID": "IE380E71D265F97C0",
+ "longitude": 10.54773,
+ "latitude": 46.77367,
+ "span": 6.0,
+ "description": "Extending nearly 750 miles from Nice, France in the west to Trieste, Italy in the east, the Alps stretch across eight different countries. The mountains formed over tens of millions of years as tectonic plates collided, forcing sedimentary rock to rise into mountain peaks. Mont Blanc is the tallest mountain in Europe, soaring over 15,700 feet. The high altitude and sheer size of the mountains have an effect on the climate in Europe.\n\nDiverse and unique flora has developed in the Alps by adapting to a high-altitude environment. The iconic Edelweiss is an alpine flower that thrives in rocky limestone. Mountain cranberry and bluets are rare and fragile plants found above treelike. Dwarf willow is a resilient plant that does well in places where snow lingers into spring.\n\nLarge mammals like the red deer, ibex, Eurasian lynx, and chamois are all found in low and high altitude regions. Many rodents such as voles and the Alpine marmot live underground and burrow in the Alps. Some reptiles including adders and vipers live near the snow line, but because they cannot tolerate the cold temperatures they hibernate underground and soak up warmth on rocky ledges.",
+ "shortDescription": "Extending nearly 750 miles from Nice, France in the west to Trieste, Italy in the east, the Alps stretch across eight different countries."
+ },
+ {
+ "name": "Mount Fuji",
+ "continent": "Asia",
+ "id": 1016,
+ "placeID": "I2CC1DF519EDD7ACD",
+ "longitude": 138.72744,
+ "latitude": 35.36072,
+ "span": 10.0,
+ "description": "When seen at a distance, Mount Fuji presents a beautiful, nearly symmetric, often snow-capped profile and is Japan’s tallest and most iconic mountain. The volcanic cone rises gracefully up to slightly more than 12,000 feet. On clear days, the mountain is visible as far away as Tokyo. Despite its seemingly peaceful distant existence, Mount Fuji is an active volcano. Its last eruption was in 1707.\n\nSimilar to other exceptionally tall mountains, Fuji-san is home to many ecological zones from its base to its summit. In the lower elevations, deciduous and coniferous trees such as the Japanese oak and cedars are common. As you climb in elevation, the climate becomes harsher and plant life transitions to alpine plants and shrubs that have adapted to colder temperatures. At the highest altitudes, a volcanic desert environment exists.\n\nMany mammals and birds are found in the forests on Mount Fuji. Black bears live there, although squirrels and fox are more likely to be seen. The Japanese serow is a rare and protected species of goat-antelope that roams secretively through dense forests. High altitude birds such as the Iwahibari and Hoshigarasu are found above 8,200 feet, while several species of warblers, flycatchers, and Ural and scops owls live in lower altitudes.",
+ "shortDescription": "When seen at a distance, Mount Fuji presents a beautiful, nearly symmetric, often snow-capped profile and is Japan’s tallest and most iconic mountain."
+ },
+ {
+ "name": "Wulingyuan",
+ "continent": "Asia",
+ "id": 1017,
+ "placeID": "I818C4BA5FE11BDD6",
+ "longitude": 110.45242,
+ "latitude": 29.35106,
+ "span": 10.0,
+ "description": "Featuring more than 3,000 sandstone pillars and peaks, Wulingyuan is a scenic and historic site in China’s Hunan Province. Often shrouded in mist, the surreal landscape of pillars surrounded by forests spans over 100 square miles. Among the picturesque lakes, rivers, and waterfalls, the site also contains 40 caves and one of the world’s highest natural bridges, named Tianqiashengkong.\n\nWith plentiful rainfall and dense forests, Wulingyuan has created a good environment for varied animal and plant life. Unique species like the clouded leopard, Chinese giant salamander, Asiatic wild dog, and Asiatic black bear live among the forests. Other animals include various monkeys — including rhesus monkeys — as well as deer, birds, and reptiles. Notable plant species include the dove tree and ginkgo.",
+ "shortDescription": "Featuring more than 3,000 sandstone pillars and peaks, Wulingyuan is a scenic and historic site in China’s Hunan Province."
+ },
+ {
+ "name": "Mount Everest",
+ "continent": "Asia",
+ "id": 1018,
+ "placeID": "IE16B9C217B9B0DC1",
+ "longitude": 86.9251,
+ "latitude": 27.98816,
+ "span": 10.0,
+ "description": "In addition to perhaps being the world’s most well-known mountain, Mount Everest is the world’s tallest above sea level. With an altitude just over 29,031 feet, the mountain attracts climbers and experienced mountaineers from all over the world. Everest’s icy peaks pierce the sky, surrounded by swirling clouds and intensely strong winds.\n\nGeologically, Mount Everest is part of the Himalayan mountain range which is formed by the Eurasian and Indian tectonic plates. Movement of these plates began around 50 million years ago and continues to push Everest even higher. Layers of metamorphic and sedimentary rock are topped by marine limestone that was once at the bottom of the ocean remind us of the Earth’s dynamic history.\n\nDespite the extreme conditions found at the high altitudes on Everest, the mountain is home to a unique high-altitude ecosystem. Mosses and lichens can survive the extreme climate along with high altitude plants such as the Himalayan juniper, dwarf rhododendrons, and the snow lotus. Animals found in the lower elevations include the Himalayan tahr, snow leopard, Himalayan black bear, and the red panda.",
+ "shortDescription": "With an altitude just over 29,031 feet, the mountain attracts climbers and experienced mountaineers from all over the world."
+ },
+ {
+ "name": "Great Barrier Reef",
+ "continent": "Australia/Oceana",
+ "id": 1019,
+ "placeID": "IF436B51611F3F9D1",
+ "longitude": 145.97842,
+ "latitude": -16.7599,
+ "span": 16.0,
+ "description": "Comprised of over 2,900 individual reefs, 900 islands, and spanning a monumental 133,000 square miles, the Great Barrier Reef is the world’s largest coral reef system. Situated in the Coral Sea just off the coast of Queensland, Australia, the Great Barrier Reef is the world’s largest single structure made by living organisms. It’s large enough to be seen from space.\n\nThe reef system supports a dizzying range of diverse life, including many species that are vulnerable or endangered, and some are endemic to the area. Dozens of cetaceans including dwarf minke whale, Indo-Pacific humpback dolphin, and the humpback whale live here. Clownfish, snapper, and coral trout are just some of the 1,500 fish species found in the waters surrounding the reef.\n\nSometimes overshadowed by the magnificent aquatic animal life, the Great Barrier Reef is home to over 2,000 species of native plants. Seagrass meadows are one of the most fundamental parts of the flora found in the reef system. Eelgrass or Turtle grass meadows thrive in the shallow coastal waters and help to maintain the biodiversity, stability, and productivity of the reef.",
+ "shortDescription": "Comprised of over 2,900 individual reefs, 900 islands, and spanning a monumental 133,000 square miles, the Great Barrier Reef is the world’s largest coral reef system."
+ },
+ {
+ "name": "Maui",
+ "continent": "Australia/Oceana",
+ "id": 1020,
+ "placeID": "I8F3C2D4A1E5B6789",
+ "longitude": -156.33170,
+ "latitude": 20.79838,
+ "span": 8.0,
+ "description": "The second-largest island in Hawaii, Maui offers a stunning tapestry of volcanic landscapes, lush rainforests, pristine beaches, and dramatic coastal cliffs. Known as the \"Valley Isle,\" Maui is dominated by two massive volcanoes: the dormant Haleakalā in the east and the older, eroded West Maui Mountains. Haleakalā National Park features a massive volcanic crater that reaches over 10,000 feet in elevation, offering breathtaking sunrise views and unique high-altitude ecosystems.\n\nThe island's diverse geography creates distinct climate zones, from arid leeward coasts to verdant windward rainforests receiving over 400 inches of annual rainfall. The famous Road to Hana winds through tropical paradise, passing countless waterfalls, bamboo forests, and dramatic ocean vistas. Maui's volcanic soil supports rich agriculture, including sugarcane, pineapple, coffee, and exotic tropical fruits.\n\nMarine life thrives in Maui's warm Pacific waters. Humpback whales migrate to the shallow channels between the Hawaiian islands from December to May, making Maui one of the world's premier whale-watching destinations. Hawaiian green sea turtles, spinner dolphins, and vibrant coral reefs attract snorkelers and divers year-round. The island's beaches range from golden sand to unique red and black volcanic shores. Native Hawaiian plants such as the silversword, which grows only on Haleakalā's slopes, and endemic bird species like the nēnē (Hawaiian goose) highlight the island's ecological significance and ongoing conservation efforts.",
+ "shortDescription": "The second-largest island in Hawaii, Maui offers a stunning tapestry of volcanic landscapes, lush rainforests, pristine beaches, and dramatic coastal cliffs."
+ },
+ {
+ "name": "South Shetland Islands",
+ "continent": "Antarctica",
+ "id": 1021,
+ "placeID": "I1AAF5FE1DF954A59",
+ "longitude": -58.70703,
+ "latitude": -61.79436,
+ "span": 20.0,
+ "description": "The South Shetland Islands consist of 11 major islands and several minor ones, making up more than 1,400 square miles of land area. The Antarctic island chain is located in the Drake Passage. Almost all of the land area is permanently covered by glaciers. Active geothermal vents and hot springs provide balance the two contrasting extremes of fire and ice.\n\nVarious plants have adapted to the harsh conditions, including two flowering plants — the Antarctic hair grass and Antarctic pearlwort. Other examples of adapted plants include mosses, liverworts, lichens, and fungi. Notable penguin species include Adélie, Chinstrap, and Gentoo. Crabeater, Leopard, Weddell, and fur seals are some of the island’s other inhabitants.",
+ "shortDescription": "The South Shetland Islands consist of 11 major islands and several minor ones, making up more than 1,400 square miles of land area."
+ }
+]
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Splash/splash.svg b/src/AI/samples/Essentials.AI.Sample/Resources/Splash/splash.svg
new file mode 100644
index 000000000000..21dfb25f187b
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/Splash/splash.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Styles/Colors.xaml b/src/AI/samples/Essentials.AI.Sample/Resources/Styles/Colors.xaml
new file mode 100644
index 000000000000..149ca54b5291
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/Styles/Colors.xaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+ #512BD4
+ #ac99ea
+ #242424
+ #DFD8F7
+ #9880e5
+ #2B0B98
+
+ White
+ Black
+ #D600AA
+ #190649
+ #1f1f1f
+
+ #E1E1E1
+ #C8C8C8
+ #ACACAC
+ #919191
+ #6E6E6E
+ #404040
+ #2D2D2D
+ #212121
+ #141414
+
+ #FFF3CD
+ #856404
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AI/samples/Essentials.AI.Sample/Resources/Styles/Styles.xaml b/src/AI/samples/Essentials.AI.Sample/Resources/Styles/Styles.xaml
new file mode 100644
index 000000000000..5fef12ae8109
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Resources/Styles/Styles.xaml
@@ -0,0 +1,434 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/BufferedChatClient.cs b/src/AI/samples/Essentials.AI.Sample/Services/BufferedChatClient.cs
new file mode 100644
index 000000000000..f5500a014bf7
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/BufferedChatClient.cs
@@ -0,0 +1,130 @@
+using System.Runtime.CompilerServices;
+using System.Text;
+using Microsoft.Extensions.AI;
+
+namespace Maui.Controls.Sample.Services;
+
+///
+/// A delegating chat client that buffers streaming text content to reduce the frequency of UI updates.
+///
+///
+///
+/// This client wraps an existing and throttles streaming responses by
+/// accumulating text content until a minimum buffer size and time delay have been met. This helps
+/// maintain smooth UI rendering and scrolling when displaying streaming AI responses.
+///
+///
+/// Non-text content such as function calls are passed through immediately without buffering, and
+/// any buffered text is flushed before such content is yielded.
+///
+///
+public class BufferedChatClient(IChatClient innerClient, int minBufferSize = 100, TimeSpan? bufferDelay = null)
+ : DelegatingChatClient(innerClient)
+{
+ private readonly int _minBufferSize = minBufferSize;
+ private readonly TimeSpan _bufferDelay = bufferDelay ?? TimeSpan.FromMilliseconds(250);
+
+ ///
+ /// Gets streaming chat response updates with buffering applied to text content.
+ ///
+ /// The chat messages to send to the model.
+ /// Optional chat options to configure the request.
+ /// A cancellation token to cancel the operation.
+ ///
+ /// An asynchronous enumerable of instances where text content
+ /// is buffered and yielded only when both the minimum buffer size and time delay conditions are met.
+ /// Non-text content is passed through immediately.
+ ///
+ ///
+ ///
+ /// The buffering behavior follows these rules:
+ ///
+ ///
+ /// - Text content is accumulated until at least the configured minimum buffer size
+ /// has been buffered AND the configured buffer delay has elapsed.
+ /// - When non-text content (such as function calls) is encountered, any buffered text is
+ /// flushed immediately, then the non-text content is yielded.
+ /// - At the end of the stream, any remaining buffered text is flushed.
+ ///
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var textBuffer = new StringBuilder();
+ var lastYieldTicks = Environment.TickCount64;
+ ChatResponseUpdate? lastUpdate = null;
+
+ await foreach (var update in InnerClient.GetStreamingResponseAsync(messages, options, cancellationToken))
+ {
+ var currentYieldTicks = Environment.TickCount64;
+ var hasNonTextContent = false;
+
+ // Check if this update has non-text content (function calls, etc.)
+ foreach (var item in update.Contents)
+ {
+ if (item is not TextContent)
+ {
+ hasNonTextContent = true;
+ break;
+ }
+ }
+
+ // If we have non-text content, flush buffer and yield immediately
+ if (hasNonTextContent)
+ {
+ if (textBuffer.Length > 0 && lastUpdate is not null)
+ {
+ yield return CreateBufferedUpdate(lastUpdate, textBuffer.ToString());
+ textBuffer.Clear();
+ lastYieldTicks = currentYieldTicks;
+ }
+
+ yield return update;
+ lastUpdate = null;
+ continue;
+ }
+
+ // Buffer text content
+ foreach (var item in update.Contents)
+ {
+ if (item is TextContent textContent)
+ {
+ textBuffer.Append(textContent.Text);
+ }
+ }
+
+ lastUpdate = update;
+
+ var shouldFlush =
+ textBuffer.Length >= _minBufferSize &&
+ TimeSpan.FromMilliseconds(currentYieldTicks - lastYieldTicks) >= _bufferDelay;
+
+ if (shouldFlush)
+ {
+ yield return CreateBufferedUpdate(update, textBuffer.ToString());
+ textBuffer.Clear();
+ lastYieldTicks = currentYieldTicks;
+ lastUpdate = null;
+ }
+ }
+
+ // Flush any remaining buffered text
+ if (textBuffer.Length > 0 && lastUpdate is not null)
+ {
+ yield return CreateBufferedUpdate(lastUpdate, textBuffer.ToString());
+ }
+ }
+
+ private static ChatResponseUpdate CreateBufferedUpdate(ChatResponseUpdate original, string newText) =>
+ new()
+ {
+ CreatedAt = original.CreatedAt,
+ FinishReason = original.FinishReason,
+ Role = original.Role,
+ Contents = [new TextContent(newText)],
+ RawRepresentation = original.RawRepresentation,
+ AdditionalProperties = original.AdditionalProperties
+ };
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/ItineraryService.cs b/src/AI/samples/Essentials.AI.Sample/Services/ItineraryService.cs
new file mode 100644
index 000000000000..7a7b9e4be770
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/ItineraryService.cs
@@ -0,0 +1,106 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Maui.Controls.Sample.Models;
+using Maui.Controls.Sample.Services.Tools;
+using Microsoft.Extensions.AI;
+
+namespace Maui.Controls.Sample.Services;
+
+#pragma warning disable CS9113 // Parameter is unread.
+public class ItineraryService(IChatClient chatClient, LandmarkDataService landmarkService)
+#pragma warning restore CS9113 // Parameter is unread.
+{
+ public record ItineraryStreamUpdate(
+ ToolLookup? ToolLookup = null,
+ ToolLookup? ToolLookupResult = null,
+ Itinerary? PartialItinerary = null);
+
+ public async IAsyncEnumerable StreamItineraryAsync(
+ Landmark landmark,
+ int dayCount,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ var jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ var findPointsOfInterestTool = new FindPointsOfInterestTool(landmark);
+ var findPointsOfInterestFunction = AIFunctionFactory.Create(findPointsOfInterestTool.Call);
+
+ string[] systemInstructions = [
+ "Your job is to create an itinerary for the person.",
+ "Each day needs an activity, hotel and restaurant.",
+ $"""
+ Always use the findPointsOfInterest tool to find businesses and activities in {landmark.Name}, especially hotels and restaurants.
+
+ The point of interest categories may include:
+ """,
+ string.Join(", ", Enum.GetNames()),
+ $"Here is a description of {landmark.Name} for your reference when considering what activities to generate:",
+ landmark.Description
+ ];
+
+ string[] userPrompt = [
+ $"Generate a {dayCount}-day itinerary to {landmark.Name}.",
+ "Give it a fun title and description.",
+ "Here is an example, but don't copy it:",
+ JsonSerializer.Serialize(Itinerary.GetExampleTripToJapan(), jsonOptions)
+ ];
+
+ var messages = new List
+ {
+ new(ChatRole.System, [.. systemInstructions.Select(s => new TextContent(s))]),
+ new(ChatRole.User, [.. userPrompt.Select(s => new TextContent(s))])
+ };
+
+ var options = new ChatOptions
+ {
+ Tools = [findPointsOfInterestFunction],
+ ResponseFormat = ChatResponseFormat.ForJsonSchema(jsonOptions)
+ };
+
+ var deserializer = new StreamingJsonDeserializer(jsonOptions);
+
+ var bufferedClient = new BufferedChatClient(chatClient, minBufferSize: 100, bufferDelay: TimeSpan.FromMilliseconds(250));
+ await foreach (var update in bufferedClient.GetStreamingResponseAsync(messages, options, cancellationToken))
+ {
+ // Detect tool calls from the streaming update
+ foreach (var item in update.Contents)
+ {
+ if (item is FunctionCallContent functionCall)
+ {
+ var toolLookup = new ToolLookup
+ {
+ Id = functionCall.CallId,
+ Arguments = functionCall.Arguments
+ };
+
+ yield return new ItineraryStreamUpdate { ToolLookup = toolLookup };
+ }
+ else if (item is FunctionResultContent functionResult)
+ {
+ var toolLookup = new ToolLookup
+ {
+ Id = functionResult.CallId,
+ Result = functionResult.Result
+ };
+
+ yield return new ItineraryStreamUpdate { ToolLookupResult = toolLookup };
+ }
+ else if (item is TextContent textContent)
+ {
+ var partialItinerary = deserializer.ProcessChunk(textContent.Text);
+ if (partialItinerary is not null)
+ {
+ yield return new ItineraryStreamUpdate { PartialItinerary = partialItinerary };
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/LandmarkDataService.cs b/src/AI/samples/Essentials.AI.Sample/Services/LandmarkDataService.cs
new file mode 100644
index 000000000000..1a8248b40f31
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/LandmarkDataService.cs
@@ -0,0 +1,57 @@
+using System.Text.Json;
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.Services;
+
+public class LandmarkDataService
+{
+ private static readonly Lazy _instance = new(() => new LandmarkDataService());
+ public static LandmarkDataService Instance => _instance.Value;
+
+ private List? _landmarks;
+ private Dictionary>? _landmarksByContinent;
+ private Dictionary? _landmarksById;
+ private Landmark? _featuredLandmark;
+
+ public IReadOnlyList Landmarks => _landmarks ?? [];
+ public IReadOnlyDictionary> LandmarksByContinent => _landmarksByContinent ?? new Dictionary>();
+ public Landmark? FeaturedLandmark => _featuredLandmark;
+
+ private LandmarkDataService()
+ {
+ LoadLandmarksAsync().ConfigureAwait(false);
+ }
+
+ public async Task LoadLandmarksAsync()
+ {
+ try
+ {
+ using var stream = await FileSystem.OpenAppPackageFileAsync("landmarkData.json");
+ using var reader = new StreamReader(stream);
+ var json = await reader.ReadToEndAsync();
+
+ _landmarks = JsonSerializer.Deserialize>(json, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ }) ?? [];
+
+ _landmarksByContinent = _landmarks
+ .GroupBy(l => l.Continent)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ _landmarksById = _landmarks.ToDictionary(l => l.Id);
+
+ _featuredLandmark = _landmarksById.GetValueOrDefault(1020);
+ }
+ catch (Exception)
+ {
+ _landmarks = [];
+ _landmarksByContinent = new Dictionary>();
+ _landmarksById = new Dictionary();
+ }
+ }
+
+ public Landmark? GetLandmarkById(int id) => _landmarksById?.GetValueOrDefault(id);
+
+ public IEnumerable GetLandmarkNames() => _landmarks?.Select(l => l.Name) ?? [];
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/StreamingJsonDeserializer.cs b/src/AI/samples/Essentials.AI.Sample/Services/StreamingJsonDeserializer.cs
new file mode 100644
index 000000000000..22678567023f
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/StreamingJsonDeserializer.cs
@@ -0,0 +1,633 @@
+using System.Buffers;
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace Maui.Controls.Sample.Services;
+
+///
+/// Zero-allocation streaming JSON deserializer using Utf8JsonWriter with ReadOnlySpan<byte> overloads.
+/// Eliminates string allocations by using the writer's native UTF-8 span APIs.
+///
+internal sealed class StreamingJsonDeserializer
+ where T : class
+{
+ private readonly ArrayBufferWriter _byteBuffer = new(initialCapacity: 4096);
+ private readonly ArrayBufferWriter _reconstructionBuffer = new(initialCapacity: 4096);
+ private readonly Stack _bracketStack = new();
+
+ private readonly JsonSerializerOptions _options;
+ private readonly bool _skipDeserialization;
+ private T? _lastGoodModel;
+
+ ///
+ /// Initializes a new instance of the StreamingJsonDeserializer.
+ ///
+ /// Custom JSON serialization options, or null to use defaults.
+ /// If true, reconstructs JSON without deserializing to the target type (useful for validation).
+ public StreamingJsonDeserializer(JsonSerializerOptions? options = null, bool skipDeserialization = false)
+ {
+ _skipDeserialization = skipDeserialization;
+ options ??= new JsonSerializerOptions();
+ _options = new JsonSerializerOptions(options)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ AllowTrailingCommas = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+
+ // Make all properties optional to handle incomplete streaming JSON
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver
+ {
+ Modifiers =
+ {
+ static typeInfo =>
+ {
+ if (typeInfo.Kind != JsonTypeInfoKind.Object)
+ return;
+
+ foreach (var propertyInfo in typeInfo.Properties)
+ {
+ propertyInfo.IsRequired = false;
+ }
+ }
+ }
+ }
+ };
+ }
+
+ ///
+ /// Gets the current accumulated JSON as UTF-8 bytes (zero-allocation view).
+ ///
+ public ReadOnlySpan PartialJsonUtf8 => _byteBuffer.WrittenSpan;
+
+ ///
+ /// Gets the current accumulated JSON as a string (allocates for string conversion).
+ ///
+ public string PartialJson => Encoding.UTF8.GetString(_byteBuffer.WrittenSpan);
+
+ ///
+ /// Gets the last successfully deserialized model, or null if none have succeeded yet.
+ ///
+ public T? LastGoodModel => _lastGoodModel;
+
+ ///
+ /// Processes a new chunk of JSON text from a streaming source.
+ /// Accumulates the chunk into the internal buffer and attempts to deserialize.
+ ///
+ /// The incoming JSON text fragment to process.
+ /// The most recently successfully deserialized model, or null if none have succeeded yet.
+ public T? ProcessChunk(string chunk)
+ {
+ if (string.IsNullOrEmpty(chunk))
+ return _lastGoodModel;
+
+ // Convert the incoming chunk to UTF-8 bytes and append to the buffer
+ var byteCount = Encoding.UTF8.GetByteCount(chunk);
+ var span = _byteBuffer.GetSpan(byteCount);
+ Encoding.UTF8.GetBytes(chunk, span);
+ _byteBuffer.Advance(byteCount);
+
+ // Attempt to deserialize the accumulated JSON so far
+ var parsed = TryDeserializeIncremental();
+ if (parsed != null)
+ {
+ _lastGoodModel = parsed;
+ }
+
+ return _lastGoodModel;
+ }
+
+ ///
+ /// Resets the deserializer state, clearing all accumulated data and cached models.
+ /// Call this to start processing a new stream from scratch.
+ ///
+ public void Reset()
+ {
+ _byteBuffer.Clear();
+ _reconstructionBuffer.Clear();
+ _bracketStack.Clear();
+ _lastGoodModel = default;
+ }
+
+ ///
+ /// Attempts to reconstruct and deserialize the accumulated JSON buffer.
+ /// Handles incomplete JSON by closing unclosed structures and extracting partial string values.
+ ///
+ /// A deserialized model instance if successful, otherwise null.
+ private T? TryDeserializeIncremental()
+ {
+ var bytes = _byteBuffer.WrittenSpan;
+ if (bytes.IsEmpty)
+ return null;
+
+ try
+ {
+ // Reconstruct valid JSON from potentially incomplete input
+ var reconstructedBytes = ReconstructValidJsonWithUtf8Writer(bytes);
+
+ if (reconstructedBytes.Length > 0)
+ {
+ if (_skipDeserialization)
+ return null;
+
+ try
+ {
+ return JsonSerializer.Deserialize(reconstructedBytes, _options);
+ }
+ catch (JsonException ex)
+ {
+ Debug.WriteLine($"Deserialization failed: {ex.Message}");
+
+ // Deserialization failed - JSON may still be too incomplete
+ return null;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Reconstruction failed: {ex.Message}");
+
+ // Reconstruction failed
+ }
+
+ return null;
+ }
+
+ ///
+ /// Reconstructs valid, complete JSON from potentially incomplete streaming JSON input.
+ /// Uses Utf8JsonReader to parse tokens and Utf8JsonWriter to rebuild valid JSON structure.
+ /// Handles incomplete values by completing partial strings and closing unclosed brackets/braces.
+ ///
+ /// The potentially incomplete UTF-8 JSON bytes to reconstruct.
+ /// A span containing complete, valid UTF-8 JSON bytes.
+ private ReadOnlySpan ReconstructValidJsonWithUtf8Writer(ReadOnlySpan incompleteUtf8Json)
+ {
+ _reconstructionBuffer.Clear();
+ _bracketStack.Clear();
+
+ // Use non-final block mode to allow incomplete JSON
+ var reader = new Utf8JsonReader(incompleteUtf8Json, isFinalBlock: false, state: default);
+ var writer = new Utf8JsonWriter(_reconstructionBuffer);
+
+ // Track pending property name for key-value pairs
+ ReadOnlySpan pendingPropertyNameBytes = default;
+
+ try
+ {
+ while (reader.Read())
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.StartObject:
+ WritePendingPropertyName(writer, ref pendingPropertyNameBytes);
+ writer.WriteStartObject();
+ // Track that this object needs closing
+ _bracketStack.Push((byte)'}');
+ break;
+
+ case JsonTokenType.EndObject:
+ // Only close if we have a matching open brace
+ if (_bracketStack.Count > 0 && _bracketStack.Peek() == (byte)'}')
+ {
+ _bracketStack.Pop();
+ writer.WriteEndObject();
+ }
+ break;
+
+ case JsonTokenType.StartArray:
+ WritePendingPropertyName(writer, ref pendingPropertyNameBytes);
+ writer.WriteStartArray();
+ // Track that this array needs closing
+ _bracketStack.Push((byte)']');
+ break;
+
+ case JsonTokenType.EndArray:
+ // Only close if we have a matching open bracket
+ if (_bracketStack.Count > 0 && _bracketStack.Peek() == (byte)']')
+ {
+ _bracketStack.Pop();
+ writer.WriteEndArray();
+ }
+ break;
+
+ case JsonTokenType.PropertyName:
+ // Store property name bytes to write later when we get the value
+ // Must copy to array since the span becomes invalid after reader advances
+ pendingPropertyNameBytes = reader.HasValueSequence
+ ? reader.ValueSequence.ToArray()
+ : reader.ValueSpan.ToArray();
+ break;
+
+ case JsonTokenType.String:
+ WriteStringValue(writer, ref reader, ref pendingPropertyNameBytes);
+ break;
+
+ case JsonTokenType.Number:
+ WriteNumberValue(writer, ref reader, ref pendingPropertyNameBytes);
+ break;
+
+ case JsonTokenType.True:
+ WriteBooleanValue(writer, true, ref pendingPropertyNameBytes);
+ break;
+
+ case JsonTokenType.False:
+ WriteBooleanValue(writer, false, ref pendingPropertyNameBytes);
+ break;
+
+ case JsonTokenType.Null:
+ WriteNullValue(writer, ref pendingPropertyNameBytes);
+ break;
+ }
+ }
+ }
+ catch
+ {
+ // Reader exhausted - expected for incomplete JSON
+ }
+
+ // If we have a property name without a value, try to extract the partial value
+ if (!pendingPropertyNameBytes.IsEmpty)
+ {
+ TryWritePartialValue(writer, incompleteUtf8Json, pendingPropertyNameBytes);
+ }
+
+ // Close any unclosed JSON structures (objects/arrays) to make the JSON valid
+ while (_bracketStack.Count > 0)
+ {
+ var bracket = _bracketStack.Pop();
+ if (bracket == (byte)'}')
+ writer.WriteEndObject();
+ else if (bracket == (byte)']')
+ writer.WriteEndArray();
+ }
+
+ writer.Flush();
+ return _reconstructionBuffer.WrittenSpan;
+ }
+
+ ///
+ /// Writes a pending property name to the JSON writer if one exists, then clears it.
+ /// Used before writing values or nested structures.
+ ///
+ private static void WritePendingPropertyName(Utf8JsonWriter writer, ref ReadOnlySpan pendingPropertyNameBytes)
+ {
+ if (!pendingPropertyNameBytes.IsEmpty)
+ {
+ // Use span-based overload for zero-allocation property name writing
+ writer.WritePropertyName(pendingPropertyNameBytes);
+ pendingPropertyNameBytes = default;
+ }
+ }
+
+ ///
+ /// Writes a string value from the reader to the writer.
+ /// Handles both property values (with pending property name) and array elements (without).
+ ///
+ private static void WriteStringValue(Utf8JsonWriter writer, ref Utf8JsonReader reader, ref ReadOnlySpan pendingPropertyNameBytes)
+ {
+ ReadOnlySpan stringBytes = GetStringBytes(ref reader);
+
+ if (!pendingPropertyNameBytes.IsEmpty)
+ {
+ // Write as property: "propertyName": "value"
+ writer.WriteString(pendingPropertyNameBytes, stringBytes);
+ pendingPropertyNameBytes = default;
+ }
+ else
+ {
+ // Write as standalone value (array element or root value)
+ writer.WriteStringValue(stringBytes);
+ }
+ }
+
+ ///
+ /// Extracts string bytes from the JSON reader, handling escape sequences if present.
+ ///
+ /// UTF-8 bytes representing the unescaped string value.
+ private static ReadOnlySpan GetStringBytes(ref Utf8JsonReader reader)
+ {
+ if (reader.ValueIsEscaped)
+ {
+ // String contains escape sequences - need to unescape them
+ var unescapedBuffer = new ArrayBufferWriter();
+ var span = unescapedBuffer.GetSpan(reader.HasValueSequence ? (int)reader.ValueSequence.Length : reader.ValueSpan.Length);
+ int written = reader.CopyString(span);
+ unescapedBuffer.Advance(written);
+ return unescapedBuffer.WrittenSpan;
+ }
+
+ // No escaping - return raw bytes (handle both contiguous and segmented buffers)
+ return reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan;
+ }
+
+ ///
+ /// Writes a numeric value from the reader to the writer.
+ /// Handles both property values and array elements, choosing the most appropriate numeric type (long, double, or decimal).
+ ///
+ private static void WriteNumberValue(Utf8JsonWriter writer, ref Utf8JsonReader reader, ref ReadOnlySpan pendingPropertyNameBytes)
+ {
+ if (!pendingPropertyNameBytes.IsEmpty)
+ {
+ // Write as property: "propertyName": 123
+ // Try integer first, then floating point, finally decimal for precision
+ if (reader.TryGetInt64(out var longValue))
+ writer.WriteNumber(pendingPropertyNameBytes, longValue);
+ else if (reader.TryGetDouble(out var doubleValue))
+ writer.WriteNumber(pendingPropertyNameBytes, doubleValue);
+ else
+ writer.WriteNumber(pendingPropertyNameBytes, reader.GetDecimal());
+
+ pendingPropertyNameBytes = default;
+ }
+ else
+ {
+ // Write as standalone value
+ if (reader.TryGetInt64(out var longValue))
+ writer.WriteNumberValue(longValue);
+ else if (reader.TryGetDouble(out var doubleValue))
+ writer.WriteNumberValue(doubleValue);
+ else
+ writer.WriteNumberValue(reader.GetDecimal());
+ }
+ }
+
+ ///
+ /// Writes a boolean value to the writer.
+ /// Handles both property values and array elements.
+ ///
+ private static void WriteBooleanValue(Utf8JsonWriter writer, bool value, ref ReadOnlySpan pendingPropertyNameBytes)
+ {
+ if (!pendingPropertyNameBytes.IsEmpty)
+ {
+ // Write as property: "propertyName": true/false
+ writer.WriteBoolean(pendingPropertyNameBytes, value);
+ pendingPropertyNameBytes = default;
+ }
+ else
+ {
+ // Write as standalone value
+ writer.WriteBooleanValue(value);
+ }
+ }
+
+ ///
+ /// Writes a null value to the writer.
+ /// Handles both property values and array elements.
+ ///
+ private static void WriteNullValue(Utf8JsonWriter writer, ref ReadOnlySpan pendingPropertyNameBytes)
+ {
+ if (!pendingPropertyNameBytes.IsEmpty)
+ {
+ // Write as property: "propertyName": null
+ writer.WriteNull(pendingPropertyNameBytes);
+ pendingPropertyNameBytes = default;
+ }
+ else
+ {
+ // Write as standalone value
+ writer.WriteNullValue();
+ }
+ }
+
+ ///
+ /// Attempts to extract and write a partially received value for a pending property.
+ /// This handles cases where streaming JSON cuts off mid-value (strings, numbers, booleans, or null).
+ ///
+ /// The JSON writer to write the completed property to.
+ /// The complete accumulated JSON buffer.
+ /// The UTF-8 bytes of the property name.
+ private static void TryWritePartialValue(Utf8JsonWriter writer, ReadOnlySpan utf8Json, ReadOnlySpan propertyNameBytes)
+ {
+ // Build search pattern for property name: "propertyName":
+ Span pattern = stackalloc byte[propertyNameBytes.Length + 3];
+ pattern[0] = (byte)'"';
+ propertyNameBytes.CopyTo(pattern[1..^2]);
+ pattern[^2] = (byte)'"';
+ pattern[^1] = (byte)':';
+
+ // Find the last occurrence of the property:value pattern
+ var index = utf8Json.LastIndexOf(pattern);
+ if (index < 0)
+ return;
+
+ // Get the value portion after the colon
+ var valueStartIndex = index + pattern.Length;
+ if (valueStartIndex >= utf8Json.Length)
+ return;
+
+ var partialValueBytes = utf8Json[valueStartIndex..];
+
+ // Skip any whitespace after the colon
+ int i = 0;
+ while (i < partialValueBytes.Length && (partialValueBytes[i] == (byte)' ' || partialValueBytes[i] == (byte)'\t' || partialValueBytes[i] == (byte)'\n' || partialValueBytes[i] == (byte)'\r'))
+ i++;
+
+ if (i >= partialValueBytes.Length)
+ return;
+
+ partialValueBytes = partialValueBytes[i..];
+
+ // Determine the value type by the first character
+ var firstChar = partialValueBytes[0];
+
+ if (firstChar == (byte)'"')
+ {
+ // String value - extract everything after the opening quote
+ var stringValueBytes = partialValueBytes[1..];
+ var unescapedBytes = UnescapeJsonStringBytes(stringValueBytes);
+ writer.WriteString(propertyNameBytes, unescapedBytes.WrittenSpan);
+ }
+ else if (firstChar == (byte)'-' || (firstChar >= (byte)'0' && firstChar <= (byte)'9'))
+ {
+ // Number value - extract digits, decimal point, exponent
+ var numberEnd = 0;
+ while (numberEnd < partialValueBytes.Length && IsNumberChar(partialValueBytes[numberEnd]))
+ numberEnd++;
+
+ if (numberEnd > 0)
+ {
+ var numberBytes = partialValueBytes[..numberEnd];
+ // Try to parse as a number using a temporary reader
+ try
+ {
+ var numberReader = new Utf8JsonReader(numberBytes, isFinalBlock: true, state: default);
+ if (numberReader.Read() && numberReader.TokenType == JsonTokenType.Number)
+ {
+ if (numberReader.TryGetInt64(out var longValue))
+ writer.WriteNumber(propertyNameBytes, longValue);
+ else if (numberReader.TryGetDouble(out var doubleValue))
+ writer.WriteNumber(propertyNameBytes, doubleValue);
+ else
+ writer.WriteNumber(propertyNameBytes, numberReader.GetDecimal());
+ }
+ }
+ catch
+ {
+ // Invalid number format - skip
+ }
+ }
+ }
+ else if (partialValueBytes.Length >= 4 && partialValueBytes[..4].SequenceEqual("true"u8))
+ {
+ writer.WriteBoolean(propertyNameBytes, true);
+ }
+ else if (partialValueBytes.Length >= 5 && partialValueBytes[..5].SequenceEqual("false"u8))
+ {
+ writer.WriteBoolean(propertyNameBytes, false);
+ }
+ else if (partialValueBytes.Length >= 4 && partialValueBytes[..4].SequenceEqual("null"u8))
+ {
+ writer.WriteNull(propertyNameBytes);
+ }
+ // else: incomplete literal (e.g., "tru", "fal", "nul") - skip for now
+ }
+
+ ///
+ /// Checks if a byte is a valid character in a JSON number (digit, decimal point, sign, or exponent).
+ ///
+ private static bool IsNumberChar(byte b)
+ {
+ return b >= (byte)'0' && b <= (byte)'9'
+ || b == (byte)'.'
+ || b == (byte)'-'
+ || b == (byte)'+'
+ || b == (byte)'e'
+ || b == (byte)'E';
+ }
+
+ ///
+ /// Unescapes JSON string escape sequences (like \n, \t, \uXXXX) in UTF-8 bytes.
+ /// Handles standard escape sequences and Unicode escape sequences.
+ ///
+ /// The UTF-8 bytes containing escape sequences.
+ /// A buffer containing the unescaped UTF-8 bytes.
+ private static ArrayBufferWriter UnescapeJsonStringBytes(ReadOnlySpan escapedBytes)
+ {
+ // Handle empty input
+ if (escapedBytes.IsEmpty)
+ return new ArrayBufferWriter();
+
+ // Remove trailing backslash if present (incomplete escape sequence)
+ if (escapedBytes[^1] == (byte)'\\')
+ escapedBytes = escapedBytes[..^1];
+
+ // Handle case where only a backslash was present
+ if (escapedBytes.IsEmpty)
+ return new ArrayBufferWriter();
+
+ var buffer = new ArrayBufferWriter(escapedBytes.Length);
+
+ for (int i = 0; i < escapedBytes.Length; i++)
+ {
+ if (escapedBytes[i] == (byte)'\\' && i + 1 < escapedBytes.Length)
+ {
+ // Process escape sequences
+ switch (escapedBytes[i + 1])
+ {
+ case (byte)'"':
+ case (byte)'\\':
+ case (byte)'/':
+ // Simple character escapes - write the escaped character directly
+ buffer.GetSpan(1)[0] = escapedBytes[i + 1];
+ buffer.Advance(1);
+ i++; // Skip the escape sequence
+ break;
+ case (byte)'b':
+ buffer.GetSpan(1)[0] = (byte)'\b';
+ buffer.Advance(1);
+ i++;
+ break;
+ case (byte)'f':
+ buffer.GetSpan(1)[0] = (byte)'\f';
+ buffer.Advance(1);
+ i++;
+ break;
+ case (byte)'n':
+ buffer.GetSpan(1)[0] = (byte)'\n';
+ buffer.Advance(1);
+ i++;
+ break;
+ case (byte)'r':
+ buffer.GetSpan(1)[0] = (byte)'\r';
+ buffer.Advance(1);
+ i++;
+ break;
+ case (byte)'t':
+ buffer.GetSpan(1)[0] = (byte)'\t';
+ buffer.Advance(1);
+ i++;
+ break;
+ case (byte)'u' when i + 5 < escapedBytes.Length:
+ // Unicode escape sequence: \uXXXX (4 hex digits)
+ if (TryParseHexToChar(escapedBytes.Slice(i + 2, 4), out char unicodeChar))
+ {
+ // Convert the Unicode character to UTF-8 bytes directly into the buffer
+ var unichars = new ReadOnlySpan(in unicodeChar);
+ var bufferBytes = buffer.GetSpan(4);
+ var bytesWritten = Encoding.UTF8.GetBytes(unichars, bufferBytes);
+ buffer.Advance(bytesWritten);
+ i += 5; // Skip \uXXXX
+ }
+ else
+ {
+ // Invalid hex - write the backslash as-is
+ buffer.GetSpan(1)[0] = escapedBytes[i];
+ buffer.Advance(1);
+ }
+ break;
+ default:
+ // Unknown escape - write the backslash as-is
+ buffer.GetSpan(1)[0] = escapedBytes[i];
+ buffer.Advance(1);
+ break;
+ }
+ }
+ else
+ {
+ // Regular character - copy as-is
+ buffer.GetSpan(1)[0] = escapedBytes[i];
+ buffer.Advance(1);
+ }
+ }
+
+ return buffer;
+ }
+
+ ///
+ /// Parses a 4-character hexadecimal sequence (like "00A9" for ©) into a Unicode character.
+ /// Used for handling \uXXXX escape sequences in JSON strings.
+ ///
+ /// UTF-8 bytes representing 4 hexadecimal digits.
+ /// The resulting Unicode character if parsing succeeded.
+ /// True if parsing succeeded, false if the hex sequence is invalid.
+ private static bool TryParseHexToChar(ReadOnlySpan hexBytes, out char result)
+ {
+ result = '\0';
+ if (hexBytes.Length != 4)
+ return false;
+
+ int value = 0;
+ foreach (byte b in hexBytes)
+ {
+ // Convert ASCII hex digit to numeric value (0-15)
+ int hexDigit = b switch
+ {
+ >= (byte)'0' and <= (byte)'9' => b - '0',
+ >= (byte)'A' and <= (byte)'F' => b - 'A' + 10,
+ >= (byte)'a' and <= (byte)'f' => b - 'a' + 10,
+ _ => -1
+ };
+
+ if (hexDigit < 0)
+ return false;
+
+ // Shift previous value left by 4 bits (one hex digit) and OR in the new digit
+ value = (value << 4) | hexDigit;
+ }
+
+ result = (char)value;
+ return true;
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/TaggingService.cs b/src/AI/samples/Essentials.AI.Sample/Services/TaggingService.cs
new file mode 100644
index 000000000000..09924002530a
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/TaggingService.cs
@@ -0,0 +1,47 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+
+namespace Maui.Controls.Sample.Services;
+
+public class TaggingService(IChatClient chatClient)
+{
+ public async Task> GenerateTagsAsync(string text, CancellationToken cancellationToken = default)
+ {
+ var systemPrompt =
+ """
+ Your job is to extract the most relevant tags from the input text.
+ """;
+
+ var userPrompt =
+ $"""
+ Extract relevant tags from this text:
+
+ {text}
+ """;
+
+ var messages = new List
+ {
+ new(ChatRole.System, systemPrompt),
+ new(ChatRole.User, userPrompt)
+ };
+
+ var response = await chatClient.GetResponseAsync(messages, cancellationToken: cancellationToken);
+ var jsonText = response.ToString();
+
+ var jsonOptions = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ return JsonSerializer.Deserialize(jsonText, jsonOptions)?.Tags ?? [];
+ }
+
+ public class TaggingResponse
+ {
+ [Description("Most important topics in the input text.")]
+ [Length(5, 5)]
+ public List Tags { get; set; } = [];
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/Tools/FindPointsOfInterestTool.cs b/src/AI/samples/Essentials.AI.Sample/Services/Tools/FindPointsOfInterestTool.cs
new file mode 100644
index 000000000000..4e3a46972c7f
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/Tools/FindPointsOfInterestTool.cs
@@ -0,0 +1,44 @@
+using System.ComponentModel;
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.Services.Tools;
+
+public class FindPointsOfInterestTool(Landmark landmark)
+{
+ public enum Category
+ {
+ Cafe,
+ Campground,
+ Hotel,
+ Marina,
+ Museum,
+ NationalMonument,
+ Restaurant,
+ }
+
+ [DisplayName("findPointsOfInterest")]
+ [Description("Finds points of interest for a landmark.")]
+ public string Call(
+ [Description("This is the type of destination to look up for.")]
+ Category pointOfInterest,
+ [Description("The natural language query of what to search for.")]
+ string naturalLanguageQuery)
+ {
+ var suggestions = GetSuggestions(pointOfInterest);
+
+ return $"There are these {pointOfInterest} in {landmark.Name}: {string.Join(", ", suggestions)}";
+ }
+
+ private static string[] GetSuggestions(Category category) =>
+ category switch
+ {
+ Category.Cafe => ["Cafe 1", "Cafe 2", "Cafe 3"],
+ Category.Campground => ["Campground 1", "Campground 2", "Campground 3"],
+ Category.Hotel => ["Hotel 1", "Hotel 2", "Hotel 3"],
+ Category.Marina => ["Marina 1", "Marina 2", "Marina 3"],
+ Category.Museum => ["Museum 1", "Museum 2", "Museum 3"],
+ Category.NationalMonument => ["The National Rock 1", "The National Rock 2", "The National Rock 3"],
+ Category.Restaurant => ["Restaurant 1", "Restaurant 2", "Restaurant 3"],
+ _ => []
+ };
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/Tools/ToolLookup.cs b/src/AI/samples/Essentials.AI.Sample/Services/Tools/ToolLookup.cs
new file mode 100644
index 000000000000..11ec348468ba
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/Tools/ToolLookup.cs
@@ -0,0 +1,10 @@
+namespace Maui.Controls.Sample.Services.Tools;
+
+public class ToolLookup
+{
+ public required string Id { get; init; }
+
+ public IDictionary? Arguments { get; set; }
+
+ public object? Result { get; set; }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Services/WeatherService.cs b/src/AI/samples/Essentials.AI.Sample/Services/WeatherService.cs
new file mode 100644
index 000000000000..4dfde72f2ed5
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Services/WeatherService.cs
@@ -0,0 +1,42 @@
+using System.Text.Json;
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.Services;
+
+public class WeatherService(HttpClient httpClient)
+{
+ public async Task GetWeatherForecastAsync(double latitude, double longitude, DateOnly date)
+ {
+ try
+ {
+ var url = $"https://api.open-meteo.com/v1/forecast?latitude={latitude:F4}&longitude={longitude:F4}&daily=temperature_2m_mean,weather_code&timezone=auto";
+
+ var response = await httpClient.GetStringAsync(url);
+ var forecast = JsonSerializer.Deserialize(response);
+
+ if (forecast?.Daily == null || forecast.Daily.Time.Count == 0)
+ {
+ return "☁️ Weather unavailable";
+ }
+
+ // Find the index for the requested date
+ var dateString = date.ToString("yyyy-MM-dd");
+ var index = forecast.Daily.Time.IndexOf(dateString);
+
+ if (index < 0 || index >= forecast.Daily.TemperatureMean.Count)
+ {
+ return "☁️ Weather unavailable";
+ }
+
+ var temp = forecast.Daily.TemperatureMean[index];
+ var weatherCode = forecast.Daily.WeatherCode[index];
+ var emoji = WeatherCodeExtensions.GetWeatherEmoji(weatherCode);
+
+ return $"{emoji} {temp:F0}°C";
+ }
+ catch
+ {
+ return "☁️ Weather unavailable";
+ }
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/ViewModels/DayPlanViewModel.cs b/src/AI/samples/Essentials.AI.Sample/ViewModels/DayPlanViewModel.cs
new file mode 100644
index 000000000000..2dd30a462679
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/ViewModels/DayPlanViewModel.cs
@@ -0,0 +1,22 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.ViewModels;
+
+public partial class DayPlanViewModel(DayPlan dayPlan, Landmark landmark, DateOnly date) : ObservableObject
+{
+ public string Title => dayPlan.Title;
+
+ public string Subtitle => dayPlan.Subtitle;
+
+ public string Destination => dayPlan.Destination;
+
+ public List Activities => dayPlan.Activities;
+
+ public Landmark Landmark { get; } = landmark;
+
+ public DateOnly Date { get; } = date;
+
+ [ObservableProperty]
+ public partial string WeatherForecast { get; set; } = "☁️ Weather unavailable";
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/ViewModels/ItineraryViewModel.cs b/src/AI/samples/Essentials.AI.Sample/ViewModels/ItineraryViewModel.cs
new file mode 100644
index 000000000000..2c7aa8816ba9
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/ViewModels/ItineraryViewModel.cs
@@ -0,0 +1,30 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.ViewModels;
+
+public partial class ItineraryViewModel(Itinerary itinerary, Landmark landmark) : ObservableObject
+{
+ public string Title => itinerary.Title;
+
+ public string Description => itinerary.Description;
+
+ public string Rationale => itinerary.Rationale;
+
+ public ObservableCollection Days =>
+ field ??= CreateDays();
+
+ private ObservableCollection CreateDays()
+ {
+ var startDate = DateOnly.FromDateTime(DateTime.Today);
+
+ var list = new ObservableCollection();
+ for (int i = 0; i < itinerary.Days.Count; i++)
+ {
+ list.Add(new DayPlanViewModel(itinerary.Days[i], landmark, startDate.AddDays(i)));
+ }
+
+ return list;
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/ViewModels/LandmarksViewModel.cs b/src/AI/samples/Essentials.AI.Sample/ViewModels/LandmarksViewModel.cs
new file mode 100644
index 000000000000..97ff7ec1f55c
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/ViewModels/LandmarksViewModel.cs
@@ -0,0 +1,51 @@
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Maui.Controls.Sample.Models;
+using Maui.Controls.Sample.Services;
+
+namespace Maui.Controls.Sample.ViewModels;
+
+public record ContinentGroup(string Name, List Landmarks);
+
+public partial class LandmarksViewModel(LandmarkDataService dataService) : ObservableObject
+{
+ [ObservableProperty]
+ public partial Landmark? FeaturedLandmark { get; private set; }
+
+ [ObservableProperty]
+ public partial bool IsLoading { get; set; }
+
+ public ObservableCollection ContinentGroups => field ??= [];
+
+ public async Task InitializeAsync()
+ {
+ if (IsLoading || ContinentGroups.Count > 0)
+ return;
+
+ await LoadLandmarksAsync();
+ }
+
+ private async Task LoadLandmarksAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ await dataService.LoadLandmarksAsync();
+
+ FeaturedLandmark = dataService.FeaturedLandmark;
+
+ ContinentGroups.Clear();
+ foreach (var continent in dataService.LandmarksByContinent.Keys.OrderBy(c => c))
+ {
+ if (dataService.LandmarksByContinent.TryGetValue(continent, out var landmarks))
+ {
+ ContinentGroups.Add(new ContinentGroup(continent, landmarks));
+ }
+ }
+ }
+ finally
+ {
+ IsLoading = false;
+ }
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/ViewModels/TripPlanningViewModel.cs b/src/AI/samples/Essentials.AI.Sample/ViewModels/TripPlanningViewModel.cs
new file mode 100644
index 000000000000..a2b805d290b6
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/ViewModels/TripPlanningViewModel.cs
@@ -0,0 +1,156 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using Maui.Controls.Sample.Models;
+using Maui.Controls.Sample.Services;
+
+namespace Maui.Controls.Sample.ViewModels;
+
+[QueryProperty(nameof(Landmark), "Landmark")]
+public partial class TripPlanningViewModel(ItineraryService itineraryService, TaggingService taggingService, WeatherService weatherService, IDispatcher dispatcher) : ObservableObject
+{
+ public enum TripPlanningState
+ {
+ Initial, // Show landmark description and generate button
+ Generating, // Show planning view with tool lookups
+ Complete, // Show full itinerary
+ Error // Show error message
+ }
+
+ [ObservableProperty]
+ public partial Landmark Landmark { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsGeneratingState))]
+ [NotifyPropertyChangedFor(nameof(HasItinerary))]
+ public partial ItineraryViewModel? Itinerary { get; set; }
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(IsInitialState))]
+ [NotifyPropertyChangedFor(nameof(IsGeneratingState))]
+ [NotifyPropertyChangedFor(nameof(HasItinerary))]
+ [NotifyPropertyChangedFor(nameof(IsErrorState))]
+ [NotifyPropertyChangedFor(nameof(IsNotErrorState))]
+ public partial TripPlanningState CurrentState { get; set; } = TripPlanningState.Initial;
+
+ [ObservableProperty]
+ public partial string? ErrorMessage { get; set; }
+
+ public bool IsInitialState => CurrentState == TripPlanningState.Initial;
+ public bool IsGeneratingState => CurrentState == TripPlanningState.Generating && Itinerary is null;
+ public bool HasItinerary => CurrentState == TripPlanningState.Complete || Itinerary is not null;
+ public bool IsErrorState => CurrentState == TripPlanningState.Error;
+ public bool IsNotErrorState => CurrentState != TripPlanningState.Error;
+
+ public ObservableCollection GeneratedTags => field ??= [];
+
+ public ObservableCollection ToolLookupHistory => field ??= [];
+
+ public ICommand GenerateItineraryCommand =>
+ field ??= new Command(async () => await RequestItineraryAsync(), () => Landmark is not null && CurrentState == TripPlanningState.Initial);
+
+ public async Task InitializeAsync()
+ {
+ if (Landmark is null || GeneratedTags.Count > 0)
+ return;
+
+ // Generate tags for the landmark description
+ await GenerateTagsAsync();
+ }
+
+ private async Task GenerateTagsAsync()
+ {
+ try
+ {
+ var tags = await taggingService.GenerateTagsAsync(Landmark.Description);
+ GeneratedTags.Clear();
+ foreach (var tag in tags)
+ {
+ GeneratedTags.Add(tag);
+ await Task.Delay(100); // Simulate slight delay for better UX
+ }
+ }
+ catch (Exception ex)
+ {
+ // Silently fail tag generation - it's not critical
+ Debug.WriteLine($"Tag generation failed: {ex.Message}");
+ }
+ }
+
+ private async Task RequestItineraryAsync()
+ {
+ CurrentState = TripPlanningState.Generating;
+ ErrorMessage = string.Empty;
+ Itinerary = null;
+ ToolLookupHistory.Clear();
+
+ try
+ {
+ // Build the itinerary
+ await Task.Run(BuildItineraryAsync);
+
+ // Fetch weather for each day
+ if (Itinerary is not null)
+ {
+ foreach (var dayVm in Itinerary.Days)
+ {
+ dayVm.WeatherForecast = await weatherService.GetWeatherForecastAsync(
+ Landmark.Latitude,
+ Landmark.Longitude,
+ dayVm.Date);
+ }
+ }
+
+ CurrentState = TripPlanningState.Complete;
+ }
+ catch (Exception ex)
+ {
+ CurrentState = TripPlanningState.Error;
+ ErrorMessage = ex.Message;
+ }
+ }
+
+ private async Task BuildItineraryAsync()
+ {
+ Itinerary? latestItinerary = null;
+
+ var lookups = new Dictionary();
+
+ // Generate itinerary with streaming updates
+ await foreach (var update in itineraryService.StreamItineraryAsync(Landmark, 3))
+ {
+ // Handle tool lookups
+ if (update.ToolLookup is not null)
+ {
+ dispatcher.Dispatch(() =>
+ {
+ var text = update.ToolLookup.Arguments?["pointOfInterest"]?.ToString() ?? "Unknown";
+ lookups[update.ToolLookup.Id] = text;
+ ToolLookupHistory.Add(text);
+ });
+ }
+
+ // Handle tool lookup results
+ if (update.ToolLookupResult is not null)
+ {
+ dispatcher.Dispatch(() =>
+ {
+ if (lookups.TryGetValue(update.ToolLookupResult.Id, out var text))
+ {
+ ToolLookupHistory.Remove(text);
+ }
+
+ ToolLookupHistory.Add(update.ToolLookupResult.Result?.ToString() ?? "Unknown Result");
+ });
+ }
+
+ // Handle partial itinerary updates
+ if (update.PartialItinerary is not null)
+ {
+ latestItinerary = update.PartialItinerary;
+ Itinerary = new ItineraryViewModel(latestItinerary, Landmark);
+ }
+ }
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ActivityListView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ActivityListView.xaml
new file mode 100644
index 000000000000..8145ad89bf7b
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ActivityListView.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ActivityListView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ActivityListView.xaml.cs
new file mode 100644
index 000000000000..ac3c77009c08
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ActivityListView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class ActivityListView : ContentView
+{
+ public ActivityListView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/DayView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/DayView.xaml
new file mode 100644
index 000000000000..0db8be14dd40
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/DayView.xaml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/DayView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/DayView.xaml.cs
new file mode 100644
index 000000000000..bdf96c0a8bfe
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/DayView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class DayView : ContentView
+{
+ public DayView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryPlanningView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryPlanningView.xaml
new file mode 100644
index 000000000000..095d5034126c
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryPlanningView.xaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryPlanningView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryPlanningView.xaml.cs
new file mode 100644
index 000000000000..0828ac21e7d1
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryPlanningView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class ItineraryPlanningView : ContentView
+{
+ public ItineraryPlanningView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryView.xaml
new file mode 100644
index 000000000000..018b349ceb33
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryView.xaml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryView.xaml.cs
new file mode 100644
index 000000000000..fa3f15bcfc16
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/ItineraryView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class ItineraryView : ContentView
+{
+ public ItineraryView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDescriptionView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDescriptionView.xaml
new file mode 100644
index 000000000000..7edd9d2d88ee
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDescriptionView.xaml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDescriptionView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDescriptionView.xaml.cs
new file mode 100644
index 000000000000..917e1d9baa63
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDescriptionView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class LandmarkDescriptionView : ContentView
+{
+ public LandmarkDescriptionView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDetailMapView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDetailMapView.xaml
new file mode 100644
index 000000000000..6e394f37b6fb
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDetailMapView.xaml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDetailMapView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDetailMapView.xaml.cs
new file mode 100644
index 000000000000..661bf2c41eac
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkDetailMapView.xaml.cs
@@ -0,0 +1,30 @@
+using Maui.Controls.Sample.Models;
+using Microsoft.Maui.Controls.Maps;
+using Microsoft.Maui.Maps;
+
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class LandmarkDetailMapView : ContentView
+{
+ public LandmarkDetailMapView()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnBindingContextChanged()
+ {
+ base.OnBindingContextChanged();
+
+ if (BindingContext is Landmark landmark)
+ {
+ // Set ItemsSource to a single-item collection for the pin
+ map.ItemsSource = new[] { landmark };
+
+ // Move map to show the landmark
+ var center = new Location(landmark.Latitude, landmark.Longitude);
+ var span = new MapSpan(center, landmark.Span, landmark.Span);
+
+ map.MoveToRegion(span);
+ }
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkTripView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkTripView.xaml
new file mode 100644
index 000000000000..14f457410bab
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkTripView.xaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkTripView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkTripView.xaml.cs
new file mode 100644
index 000000000000..d998030011d1
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/LandmarkTripView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class LandmarkTripView : ContentView
+{
+ public LandmarkTripView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/MessageView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/MessageView.xaml
new file mode 100644
index 000000000000..6d8fbab6cd09
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/MessageView.xaml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/MessageView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/MessageView.xaml.cs
new file mode 100644
index 000000000000..aa962edb2396
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Itinerary/MessageView.xaml.cs
@@ -0,0 +1,9 @@
+namespace Maui.Controls.Sample.Views.Itinerary;
+
+public partial class MessageView : ContentView
+{
+ public MessageView()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkFeaturedItemView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkFeaturedItemView.xaml
new file mode 100644
index 000000000000..f479e5ac76ea
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkFeaturedItemView.xaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkFeaturedItemView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkFeaturedItemView.xaml.cs
new file mode 100644
index 000000000000..4b0188b1e7b9
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkFeaturedItemView.xaml.cs
@@ -0,0 +1,30 @@
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.Views.Landmarks;
+
+public partial class LandmarkFeaturedItemView : ContentView
+{
+ public static readonly BindableProperty LandmarkProperty =
+ BindableProperty.Create(nameof(Landmark), typeof(Landmark), typeof(LandmarkFeaturedItemView), null);
+
+ public Landmark? Landmark
+ {
+ get => (Landmark?)GetValue(LandmarkProperty);
+ set => SetValue(LandmarkProperty, value);
+ }
+
+ public event EventHandler? LandmarkTapped;
+
+ public LandmarkFeaturedItemView()
+ {
+ InitializeComponent();
+ }
+
+ private void OnFeaturedLandmarkTapped(object? sender, TappedEventArgs e)
+ {
+ if (Landmark is not null)
+ {
+ LandmarkTapped?.Invoke(this, Landmark);
+ }
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkHorizontalListView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkHorizontalListView.xaml
new file mode 100644
index 000000000000..dc27987a293e
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkHorizontalListView.xaml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkHorizontalListView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkHorizontalListView.xaml.cs
new file mode 100644
index 000000000000..2b7bd2af4d8a
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkHorizontalListView.xaml.cs
@@ -0,0 +1,28 @@
+using System.Collections;
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.Views.Landmarks;
+
+public partial class LandmarkHorizontalListView : ContentView
+{
+ public static readonly BindableProperty LandmarksProperty =
+ BindableProperty.Create(nameof(Landmarks), typeof(IEnumerable), typeof(LandmarkHorizontalListView), null);
+
+ public IEnumerable? Landmarks
+ {
+ get => (IEnumerable?)GetValue(LandmarksProperty);
+ set => SetValue(LandmarksProperty, value);
+ }
+
+ public event EventHandler? LandmarkTapped;
+
+ public LandmarkHorizontalListView()
+ {
+ InitializeComponent();
+ }
+
+ private void OnLandmarkItemTapped(object? sender, Landmark landmark)
+ {
+ LandmarkTapped?.Invoke(this, landmark);
+ }
+}
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkListItemView.xaml b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkListItemView.xaml
new file mode 100644
index 000000000000..9b2f86a964ef
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkListItemView.xaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkListItemView.xaml.cs b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkListItemView.xaml.cs
new file mode 100644
index 000000000000..99c03ef43e31
--- /dev/null
+++ b/src/AI/samples/Essentials.AI.Sample/Views/Landmarks/LandmarkListItemView.xaml.cs
@@ -0,0 +1,30 @@
+using Maui.Controls.Sample.Models;
+
+namespace Maui.Controls.Sample.Views.Landmarks;
+
+public partial class LandmarkListItemView : ContentView
+{
+ public static readonly BindableProperty LandmarkProperty =
+ BindableProperty.Create(nameof(Landmark), typeof(Landmark), typeof(LandmarkListItemView), null);
+
+ public Landmark? Landmark
+ {
+ get => (Landmark?)GetValue(LandmarkProperty);
+ set => SetValue(LandmarkProperty, value);
+ }
+
+ public event EventHandler? LandmarkTapped;
+
+ public LandmarkListItemView()
+ {
+ InitializeComponent();
+ }
+
+ private void OnLandmarkTapped(object? sender, TappedEventArgs e)
+ {
+ if (Landmark is not null)
+ {
+ LandmarkTapped?.Invoke(this, Landmark);
+ }
+ }
+}
diff --git a/src/AI/src/AppleNative/.editorconfig b/src/AI/src/AppleNative/.editorconfig
new file mode 100644
index 000000000000..ba23728abea8
--- /dev/null
+++ b/src/AI/src/AppleNative/.editorconfig
@@ -0,0 +1,34 @@
+# EditorConfig for Swift/Apple Native code
+# https://editorconfig.org
+
+root = true
+
+[*.swift]
+charset = utf-8
+indent_style = space
+indent_size = 4
+tab_width = 4
+end_of_line = lf
+insert_final_newline = true
+max_line_length = 160
+trim_trailing_whitespace = true
+
+# Xcode project files
+[*.pbxproj]
+indent_style = tab
+end_of_line = lf
+insert_final_newline = true
+
+# Xcode scheme files
+[*.xcscheme]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+
+# Plist files
+[*.plist]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
diff --git a/src/AI/src/AppleNative/.gitignore b/src/AI/src/AppleNative/.gitignore
new file mode 100644
index 000000000000..fda4de3a857d
--- /dev/null
+++ b/src/AI/src/AppleNative/.gitignore
@@ -0,0 +1,62 @@
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## Obj-C/Swift specific
+*.hmap
+
+## App packaging
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+#
+# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
+# Packages/
+# Package.pins
+# Package.resolved
+# *.xcodeproj
+#
+# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
+# hence it is not needed unless you have added a package configuration file to your project
+# .swiftpm
+
+.build/
+
+# CocoaPods
+#
+# We recommend against adding the Pods directory to your .gitignore. However
+# you should judge for yourself, the pros and cons are mentioned at:
+# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
+#
+# Pods/
+#
+# Add this line if you want to avoid checking in source code from the Xcode workspace
+# *.xcworkspace
+
+# Carthage
+#
+# Add this line if you want to avoid checking in source code from Carthage dependencies.
+# Carthage/Checkouts
+
+Carthage/Build/
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo.
+# Instead, use fastlane to re-generate the screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/#source-control
+
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
\ No newline at end of file
diff --git a/src/AI/src/AppleNative/AppleIntelligenceLogger.swift b/src/AI/src/AppleNative/AppleIntelligenceLogger.swift
new file mode 100644
index 000000000000..54b792333601
--- /dev/null
+++ b/src/AI/src/AppleNative/AppleIntelligenceLogger.swift
@@ -0,0 +1,13 @@
+import Foundation
+import FoundationModels
+
+/// Type alias for the logging action block.
+public typealias AppleIntelligenceLogAction = (String) -> Void
+
+/// Singleton holder for the logger action.
+@objc(AppleIntelligenceLogger)
+public class AppleIntelligenceLogger: NSObject {
+ /// The logging action. Set this to receive log callbacks.
+ /// Example: AppleIntelligenceLogger.log = { message in print("[Native] \(message)") }
+ @objc public static var log: AppleIntelligenceLogAction?
+}
diff --git a/src/AI/src/AppleNative/Cancellation.swift b/src/AI/src/AppleNative/Cancellation.swift
new file mode 100644
index 000000000000..fe6095593bb3
--- /dev/null
+++ b/src/AI/src/AppleNative/Cancellation.swift
@@ -0,0 +1,19 @@
+import Foundation
+import FoundationModels
+
+@objc(CancellationTokenNative)
+public class CancellationTokenNative: NSObject {
+ private var task: Task
+
+ init(task: Task) {
+ self.task = task
+ }
+
+ @objc public func cancel() {
+ task.cancel()
+ }
+
+ @objc public var isCancelled: Bool {
+ task.isCancelled
+ }
+}
diff --git a/src/AI/src/AppleNative/ChatClient.swift b/src/AI/src/AppleNative/ChatClient.swift
new file mode 100644
index 000000000000..82eafcb2953c
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatClient.swift
@@ -0,0 +1,545 @@
+import Foundation
+import FoundationModels
+
+@objc(ChatClientError)
+public enum ChatClientError: Int {
+ case emptyMessages = 1
+ case invalidRole = 2
+ case invalidContent = 3
+ case cancelled = 4
+}
+
+@objc(ChatClientNative)
+public class ChatClientNative: NSObject {
+
+ // MARK: - Stream Response
+
+ @objc public func streamResponse(
+ messages: [ChatMessageNative],
+ options: ChatOptionsNative?,
+ onUpdate: @escaping (ResponseUpdateNative) -> Void,
+ onComplete: @escaping (ChatResponseNative?, NSError?) -> Void
+ ) -> CancellationTokenNative? {
+
+ let methodName = "streamResponse"
+ let cq = OperationQueue.current?.underlyingQueue
+
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Invoked with \(messages.count) messages")
+ log("[\(methodName)] Messages: \(formatMessagesDetailed(messages))")
+ if let opts = options {
+ log("[\(methodName)] Options: \(formatOptionsDetailed(opts))")
+ }
+ }
+
+ let toolWatcher =
+ options?.tools == nil
+ ? nil
+ : ToolCallWatcher(
+ onToolCall: { id, name, arguments in
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Tool invoking: \(name) (id=\(id)) with arguments: \(arguments)")
+ }
+
+ let update = ResponseUpdateNative(updateType: .toolCall, toolCallId: id, toolCallName: name, toolCallArguments: arguments)
+ cq?.async { onUpdate(update) } ?? onUpdate(update)
+ },
+ onToolResult: { id, name, result in
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Tool completed: \(name) (id=\(id)) with result: \(result)")
+ }
+
+ let update = ResponseUpdateNative(updateType: .toolResult, toolCallId: id, toolCallName: name, toolCallResult: result)
+ cq?.async { onUpdate(update) } ?? onUpdate(update)
+ }
+ )
+
+ return executeTask(methodName, messages, options, toolWatcher, onComplete) { session, prompt, schema, genOptions in
+
+ if let jsonSchema = schema {
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Starting schema-based stream response")
+ }
+
+ let responseStream = session.streamResponse(to: prompt, schema: jsonSchema, includeSchemaInPrompt: false, options: genOptions)
+
+ for try await response in responseStream {
+ try Task.checkCancellation()
+ let text = response.content.jsonString
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Streaming update: \(text)")
+ }
+ let update = ResponseUpdateNative(updateType: .content, text: text)
+ cq?.async { onUpdate(update) } ?? onUpdate(update)
+ }
+
+ let response = try await responseStream.collect()
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Stream collected, content length: \(response.content.jsonString.count)")
+ }
+ return (response.content.jsonString, response.transcriptEntries)
+ } else {
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Starting text-based stream response")
+ }
+
+ let responseStream = session.streamResponse(to: prompt, options: genOptions)
+
+ for try await response in responseStream {
+ try Task.checkCancellation()
+ let text = response.content
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Streaming update: \(text)")
+ }
+ let update = ResponseUpdateNative(updateType: .content, text: text)
+ cq?.async { onUpdate(update) } ?? onUpdate(update)
+ }
+
+ let response = try await responseStream.collect()
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Stream collected, content length: \(response.content.count)")
+ }
+ return (response.content, response.transcriptEntries)
+ }
+ }
+
+ }
+
+ // MARK: - Get Response
+
+ @objc public func getResponse(
+ messages: [ChatMessageNative],
+ options: ChatOptionsNative?,
+ onUpdate: @escaping (ResponseUpdateNative) -> Void,
+ onComplete: @escaping (ChatResponseNative?, NSError?) -> Void
+ ) -> CancellationTokenNative? {
+
+ let methodName = "getResponse"
+ let cq = OperationQueue.current?.underlyingQueue
+
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Invoked with \(messages.count) messages")
+ log("[\(methodName)] Messages: \(formatMessagesDetailed(messages))")
+ if let opts = options {
+ log("[\(methodName)] Options: \(formatOptionsDetailed(opts))")
+ }
+ }
+
+ let toolWatcher =
+ options?.tools == nil
+ ? nil
+ : ToolCallWatcher(
+ onToolCall: { id, name, arguments in
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Tool invoking: \(name) (id=\(id)) with arguments: \(arguments)")
+ }
+
+ let update = ResponseUpdateNative(updateType: .toolCall, toolCallId: id, toolCallName: name, toolCallArguments: arguments)
+ cq?.async { onUpdate(update) } ?? onUpdate(update)
+ },
+ onToolResult: { id, name, result in
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Tool completed: \(name) (id=\(id)) with result: \(result)")
+ }
+
+ let update = ResponseUpdateNative(updateType: .toolResult, toolCallId: id, toolCallName: name, toolCallResult: result)
+ cq?.async { onUpdate(update) } ?? onUpdate(update)
+ }
+ )
+
+ return executeTask(methodName, messages, options, toolWatcher, onComplete) { session, prompt, schema, genOptions in
+
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] \(schema != nil ? "Getting schema-based response" : "Getting text-based response")")
+ }
+
+ let response = try await {
+ if let jsonSchema = schema {
+ let inner = try await session.respond(to: prompt, schema: jsonSchema, includeSchemaInPrompt: false, options: genOptions)
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Response received, content length: \(inner.content.jsonString.count)")
+ }
+ return (inner.content.jsonString, inner.transcriptEntries)
+ } else {
+ let inner = try await session.respond(to: prompt, options: genOptions)
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Response received, content length: \(inner.content.count)")
+ }
+ return (inner.content, inner.transcriptEntries)
+ }
+ }()
+
+ return response
+ }
+ }
+
+ // MARK: - Session Helpers
+
+ private func prepareSession(
+ _ methodName: String,
+ _ messages: [ChatMessageNative],
+ _ options: ChatOptionsNative?,
+ _ toolWatcher: ToolCallWatcher?
+ ) async throws -> (
+ session: LanguageModelSession,
+ prompt: Prompt,
+ schema: GenerationSchema?,
+ genOptions: GenerationOptions
+ ) {
+
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Preparing session with \(messages.count) messages, hasTools=\(options?.tools != nil)")
+ }
+
+ let lastMessage = messages.last!
+ let otherMessages = messages.dropLast()
+
+ let model = SystemLanguageModel.default
+ let tools = options?.tools?.map { ToolNative($0, toolWatcher?.notifyToolCall, toolWatcher?.notifyToolResult) } ?? []
+
+ if let log = AppleIntelligenceLogger.log, let toolList = options?.tools {
+ for tool in toolList {
+ log("[\(methodName)] Tool registered: \(tool.name) - \(tool.desc)")
+ log("[\(methodName)] Tool \(tool.name) argumentsSchema: \(tool.argumentsSchema)")
+ log("[\(methodName)] Tool \(tool.name) outputSchema: \(tool.outputSchema)")
+ }
+ }
+
+ let transcript = try Transcript(entries: otherMessages.map(self.toTranscriptEntry))
+ let prompt = try self.toPrompt(message: lastMessage)
+
+ // Parse the JSON schema from the options
+ let schema: GenerationSchema? = try {
+ if let jsonSchema = options?.responseJsonSchema {
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Parsing JSON schema for structured output: \(jsonSchema)")
+ }
+
+ let parsed = try JsonSchemaDecoder.parse(String(jsonSchema))
+ return parsed
+ }
+ return nil
+ }()
+
+ // Map options into GenerationOptions
+ let genOptions = GenerationOptions(
+ sampling: {
+ if let topK = options?.topK?.intValue {
+ return .random(top: topK, seed: options?.seed?.uint64Value)
+ }
+ return .greedy
+ }(),
+ temperature: options?.temperature?.doubleValue,
+ maximumResponseTokens: options?.maxOutputTokens?.intValue
+ )
+
+ let session = LanguageModelSession(model: model, tools: tools, transcript: transcript)
+
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Session ready, hasSchema=\(schema != nil)")
+ }
+
+ return (session, prompt, schema, genOptions)
+ }
+
+ private func executeTask(
+ _ methodName: String,
+ _ messages: [ChatMessageNative],
+ _ options: ChatOptionsNative?,
+ _ toolWatcher: ToolCallWatcher?,
+ _ onComplete: @escaping (ChatResponseNative?, NSError?) -> Void,
+ operation:
+ @escaping (LanguageModelSession, Prompt, GenerationSchema?, GenerationOptions) async throws
+ -> (String, ArraySlice)
+ ) -> CancellationTokenNative? {
+
+ let cq = OperationQueue.current?.underlyingQueue
+
+ guard !messages.isEmpty else {
+ let error = NSError.chatError(.emptyMessages, description: "No messages provided.")
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Failed: No messages provided")
+ }
+
+ cq?.async { onComplete(nil, error.toNSError()) } ?? onComplete(nil, error.toNSError())
+ return nil
+ }
+
+ let task = Task {
+ do {
+ try Task.checkCancellation()
+
+ let (session, prompt, schema, genOptions) = try await self.prepareSession(methodName, messages, options, toolWatcher)
+
+ let result = try await operation(session, prompt, schema, genOptions)
+
+ try Task.checkCancellation()
+
+ // Convert transcript entries to messages
+ let transcriptMessages = try result.1.compactMap(self.fromTranscriptEntry)
+
+ // Create response with all transcript messages
+ let response = ChatResponseNative(messages: transcriptMessages)
+
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Completed with \(transcriptMessages.count) messages")
+ log("[\(methodName)] Response: \(self.formatResponseDetailed(response))")
+ }
+
+ cq?.async { onComplete(response, nil) } ?? onComplete(response, nil)
+ } catch {
+ if let log = AppleIntelligenceLogger.log {
+ log("[\(methodName)] Failed: \(error.localizedDescription)")
+ }
+
+ cq?.async { onComplete(nil, error.toNSError()) } ?? onComplete(nil, error.toNSError())
+ }
+ }
+
+ return CancellationTokenNative(task: task)
+ }
+
+ // MARK: - Conversion to Foundation Models Helpers
+
+ private func toPrompt(message: ChatMessageNative) throws -> Prompt {
+ guard message.role == .user else {
+ throw NSError.chatError(.invalidRole, description: "Only user messages can be prompts. Found: \(message.role)")
+ }
+
+ return try Prompt {
+ try message.contents.map {
+ switch $0 {
+ case let textContent as TextContentNative:
+ return textContent.text
+ default:
+ throw NSError.chatError(.invalidContent, description: "Unsupported content type in prompt. Found: \(type(of: $0))")
+ }
+ }
+ }
+ }
+
+ private func toTranscriptEntry(message: ChatMessageNative) throws -> Transcript.Entry {
+ switch message.role {
+ case .user:
+ return try .prompt(Transcript.Prompt(segments: message.contents.map(self.toTranscriptSegment)))
+ case .assistant:
+ return try .response(Transcript.Response(assetIDs: [], segments: message.contents.map(self.toTranscriptSegment)))
+ case .system:
+ return try .instructions(Transcript.Instructions(segments: message.contents.map(self.toTranscriptSegment), toolDefinitions: []))
+ default:
+ throw NSError.chatError(.invalidRole, description: "Unsupported role in transcript. Found: \(message.role)")
+ }
+ }
+
+ private func toTranscriptSegment(content: AIContentNative) throws -> Transcript.Segment {
+ switch content {
+ case let textContent as TextContentNative:
+ return .text(Transcript.TextSegment(content: textContent.text))
+ default:
+ throw NSError.chatError(.invalidContent, description: "Unsupported content type in transcript. Found: \(type(of: content))")
+ }
+ }
+
+ // MARK: - Conversion to Essentials AI Helpers
+
+ private func fromTranscriptEntry(_ entry: Transcript.Entry) throws -> ChatMessageNative? {
+ switch entry {
+ case .prompt(let prompt):
+ let message = ChatMessageNative()
+ message.role = .user
+ message.contents = prompt.segments.compactMap(fromTranscriptSegment)
+ return message
+
+ case .response(let response):
+ let message = ChatMessageNative()
+ message.role = .assistant
+ message.contents = response.segments.compactMap(fromTranscriptSegment)
+ return message
+
+ case .instructions(let instructions):
+ let message = ChatMessageNative()
+ message.role = .system
+ message.contents = instructions.segments.compactMap(fromTranscriptSegment)
+ return message
+
+ case .toolCalls(let toolCalls):
+ let message = ChatMessageNative()
+ message.role = .assistant
+ message.contents = toolCalls.map(fromToolCall)
+ return message
+
+ case .toolOutput(let toolOutput):
+ let message = ChatMessageNative()
+ message.role = .tool
+ message.contents = fromToolOutput(toolOutput)
+ return message
+
+ @unknown default:
+ return nil
+ }
+ }
+
+ private func fromToolCall(_ toolCall: Transcript.ToolCall) -> AIContentNative {
+ let argsJson = toolCall.arguments.jsonString
+ return FunctionCallContentNative(callId: toolCall.id, name: toolCall.toolName, arguments: argsJson)
+ }
+
+ private func fromToolOutput(_ toolOutput: Transcript.ToolOutput) -> [AIContentNative] {
+ return toolOutput.segments.compactMap { segment -> AIContentNative? in
+ let resultText: String
+ switch segment {
+ case .text(let textSegment):
+ resultText = textSegment.content
+ case .structure(let structuredSegment):
+ resultText = structuredSegment.content.jsonString
+ @unknown default:
+ return nil
+ }
+
+ return FunctionResultContentNative(callId: toolOutput.id, result: resultText)
+ }
+ }
+
+ private func fromTranscriptSegment(_ segment: Transcript.Segment) -> AIContentNative? {
+ switch segment {
+ case .text(let textSegment):
+ return TextContentNative(text: textSegment.content)
+
+ case .structure(let structuredSegment):
+ // For now, convert structured content to text
+ let jsonString = structuredSegment.content.jsonString
+ return TextContentNative(text: jsonString)
+
+ @unknown default:
+ return nil
+ }
+ }
+
+ // MARK: - Logging Helpers
+
+ private func formatMessagesDetailed(_ messages: [ChatMessageNative]) -> String {
+ let formatted = messages.map { message -> String in
+ let role = "\(message.role)"
+ let contents = message.contents.map { content -> String in
+ switch content {
+ case let text as TextContentNative:
+ return "TextContent(text=\"\(text.text)\")"
+ case let funcCall as FunctionCallContentNative:
+ return "FunctionCallContent(name=\"\(funcCall.name)\", callId=\"\(funcCall.callId)\", arguments=\(funcCall.arguments))"
+ case let funcResult as FunctionResultContentNative:
+ return "FunctionResultContent(callId=\"\(funcResult.callId)\", result=\"\(funcResult.result)\")"
+ default:
+ return "UnknownContent(\(type(of: content)))"
+ }
+ }.joined(separator: ", ")
+ return "Message(role=\(role), contents=[\(contents)])"
+ }.joined(separator: ", ")
+ return "[\(formatted)]"
+ }
+
+ private func formatOptionsDetailed(_ options: ChatOptionsNative) -> String {
+ var parts: [String] = []
+ if let topK = options.topK { parts.append("topK=\(topK)") }
+ if let temp = options.temperature { parts.append("temperature=\(temp)") }
+ if let maxTokens = options.maxOutputTokens { parts.append("maxOutputTokens=\(maxTokens)") }
+ if let seed = options.seed { parts.append("seed=\(seed)") }
+ if let schema = options.responseJsonSchema { parts.append("responseJsonSchema=\(schema)") }
+ if let tools = options.tools {
+ let toolNames = tools.map { "\($0.name)" }.joined(separator: ", ")
+ parts.append("tools=[\(toolNames)]")
+ }
+ return "Options(\(parts.joined(separator: ", ")))"
+ }
+
+ private func formatResponseDetailed(_ response: ChatResponseNative) -> String {
+ let messagesStr = response.messages.map { message -> String in
+ let role = "\(message.role)"
+ let contents = message.contents.map { content -> String in
+ switch content {
+ case let text as TextContentNative:
+ return "TextContent(text=\"\(text.text)\")"
+ case let funcCall as FunctionCallContentNative:
+ return "FunctionCallContent(name=\"\(funcCall.name)\", callId=\"\(funcCall.callId)\", arguments=\(funcCall.arguments))"
+ case let funcResult as FunctionResultContentNative:
+ return "FunctionResultContent(callId=\"\(funcResult.callId)\", result=\"\(funcResult.result)\")"
+ default:
+ return "UnknownContent(\(type(of: content)))"
+ }
+ }.joined(separator: ", ")
+ return "Message(role=\(role), contents=[\(contents)])"
+ }.joined(separator: ", ")
+ return "ChatResponse(messages=[\(messagesStr)])"
+ }
+}
+
+extension NSError {
+
+ fileprivate static func chatError(_ code: ChatClientError, description: String) -> NSError {
+ NSError(
+ domain: "ChatClientNative",
+ code: code.rawValue,
+ userInfo: [NSLocalizedDescriptionKey: description]
+ )
+ }
+}
+
+extension Error {
+
+ fileprivate func toNSError() -> NSError {
+ switch self
+ {
+ case let error as LanguageModelSession.GenerationError:
+ return NSError(
+ domain: "ChatClientNative",
+ code: 0,
+ userInfo: [
+ NSUnderlyingErrorKey: error.errorDescription ?? "",
+ NSLocalizedRecoverySuggestionErrorKey: error.recoverySuggestion ?? "",
+ NSLocalizedFailureReasonErrorKey: error.failureReason ?? "",
+ NSLocalizedDescriptionKey: error.localizedDescription,
+ ]
+ )
+
+ case let error as LanguageModelSession.ToolCallError:
+ return NSError(
+ domain: "ChatClientNative",
+ code: 0,
+ userInfo: [
+ NSUnderlyingErrorKey: error.errorDescription ?? "",
+ NSLocalizedRecoverySuggestionErrorKey: error.recoverySuggestion ?? "",
+ NSLocalizedFailureReasonErrorKey: error.failureReason ?? "",
+ NSLocalizedDescriptionKey: error.localizedDescription,
+ ]
+ )
+
+ case let error as LocalizedError:
+ return NSError(
+ domain: "ChatClientNative",
+ code: 0,
+ userInfo: [
+ NSUnderlyingErrorKey: error.errorDescription ?? "",
+ NSLocalizedRecoverySuggestionErrorKey: error.recoverySuggestion ?? "",
+ NSLocalizedFailureReasonErrorKey: error.failureReason ?? "",
+ NSLocalizedDescriptionKey: error.localizedDescription,
+ ]
+ )
+
+ case is CancellationError:
+ return NSError.chatError(.cancelled, description: "Request was cancelled.")
+
+ case let error as NSError:
+ return error
+
+ default:
+ return NSError(
+ domain: "ChatClientNative",
+ code: 0,
+ userInfo: [
+ NSLocalizedDescriptionKey: self.localizedDescription
+ ]
+ )
+ }
+
+ }
+
+}
diff --git a/src/AI/src/AppleNative/ChatMessage.swift b/src/AI/src/AppleNative/ChatMessage.swift
new file mode 100644
index 000000000000..0b42fe925bfd
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatMessage.swift
@@ -0,0 +1,16 @@
+import Foundation
+import FoundationModels
+
+@objc(ChatRoleNative)
+public enum ChatRoleNative: Int {
+ case user = 1
+ case assistant = 2
+ case system = 3
+ case tool = 4
+}
+
+@objc(ChatMessageNative)
+public class ChatMessageNative: NSObject {
+ @objc public var role: ChatRoleNative = .user
+ @objc public var contents: [AIContentNative] = []
+}
diff --git a/src/AI/src/AppleNative/ChatMessageContent.swift b/src/AI/src/AppleNative/ChatMessageContent.swift
new file mode 100644
index 000000000000..073c307a13ca
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatMessageContent.swift
@@ -0,0 +1,39 @@
+import Foundation
+import FoundationModels
+
+@objc(AIContentNative)
+public class AIContentNative: NSObject {}
+
+@objc(TextContentNative)
+public class TextContentNative: AIContentNative {
+ @objc public init(text: String) {
+ self.text = text
+ }
+ @objc public var text: String
+}
+
+@objc(FunctionCallContentNative)
+public class FunctionCallContentNative: AIContentNative {
+ @objc public var callId: String
+ @objc public var name: String
+ @objc public var arguments: String // JSON string
+
+ @objc public init(callId: String, name: String, arguments: String) {
+ self.callId = callId
+ self.name = name
+ self.arguments = arguments
+ super.init()
+ }
+}
+
+@objc(FunctionResultContentNative)
+public class FunctionResultContentNative: AIContentNative {
+ @objc public var callId: String
+ @objc public var result: String
+
+ @objc public init(callId: String, result: String) {
+ self.callId = callId
+ self.result = result
+ super.init()
+ }
+}
diff --git a/src/AI/src/AppleNative/ChatOptions.swift b/src/AI/src/AppleNative/ChatOptions.swift
new file mode 100644
index 000000000000..09fefba473e5
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatOptions.swift
@@ -0,0 +1,12 @@
+import Foundation
+import FoundationModels
+
+@objc(ChatOptionsNative)
+public class ChatOptionsNative: NSObject {
+ @objc public var topK: NSNumber? = nil
+ @objc public var seed: NSNumber? = nil
+ @objc public var temperature: NSNumber? = nil
+ @objc public var maxOutputTokens: NSNumber? = nil
+ @objc public var responseJsonSchema: NSString? = nil
+ @objc public var tools: [any AIToolNative]? = nil
+}
diff --git a/src/AI/src/AppleNative/ChatResponseNative.swift b/src/AI/src/AppleNative/ChatResponseNative.swift
new file mode 100644
index 000000000000..09beda7c5f50
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatResponseNative.swift
@@ -0,0 +1,12 @@
+import Foundation
+import FoundationModels
+
+@objc(ChatResponseNative)
+public class ChatResponseNative: NSObject, @unchecked Sendable {
+ @objc public var messages: [ChatMessageNative]
+
+ @objc public init(messages: [ChatMessageNative]) {
+ self.messages = messages
+ super.init()
+ }
+}
diff --git a/src/AI/src/AppleNative/ChatResponseUpdateNative.swift b/src/AI/src/AppleNative/ChatResponseUpdateNative.swift
new file mode 100644
index 000000000000..2b3bea8b5b6d
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatResponseUpdateNative.swift
@@ -0,0 +1,36 @@
+import Foundation
+import FoundationModels
+
+@objc(ResponseUpdateTypeNative)
+public enum ResponseUpdateTypeNative: Int, Sendable {
+ case content = 0
+ case toolCall = 1
+ case toolResult = 2
+}
+
+@objc(ResponseUpdateNative)
+public final class ResponseUpdateNative: NSObject, Sendable {
+ @objc public let updateType: ResponseUpdateTypeNative
+ @objc public let text: String?
+ @objc public let toolCallId: String?
+ @objc public let toolCallName: String?
+ @objc public let toolCallArguments: String?
+ @objc public let toolCallResult: String?
+
+ @objc public init(
+ updateType: ResponseUpdateTypeNative = .content,
+ text: String? = nil,
+ toolCallId: String? = nil,
+ toolCallName: String? = nil,
+ toolCallArguments: String? = nil,
+ toolCallResult: String? = nil
+ ) {
+ self.updateType = updateType
+ self.text = text
+ self.toolCallId = toolCallId
+ self.toolCallName = toolCallName
+ self.toolCallArguments = toolCallArguments
+ self.toolCallResult = toolCallResult
+ super.init()
+ }
+}
diff --git a/src/AI/src/AppleNative/ChatTool.swift b/src/AI/src/AppleNative/ChatTool.swift
new file mode 100644
index 000000000000..94957eb4f231
--- /dev/null
+++ b/src/AI/src/AppleNative/ChatTool.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+@objc(AIToolNative)
+public protocol AIToolNative : Sendable {
+ @objc var name: String { get }
+ @objc var desc: String { get }
+ @objc var argumentsSchema: String { get }
+ @objc var outputSchema: String { get }
+ @objc func call(arguments: String, completion: @escaping (String) -> Void)
+}
diff --git a/src/AI/src/AppleNative/EssentialsAI.xcodeproj/project.pbxproj b/src/AI/src/AppleNative/EssentialsAI.xcodeproj/project.pbxproj
new file mode 100644
index 000000000000..f651cd23afe9
--- /dev/null
+++ b/src/AI/src/AppleNative/EssentialsAI.xcodeproj/project.pbxproj
@@ -0,0 +1,418 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 340019332EDF4D6800DAB0A3 /* JsonSchemaDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019302EDF4D6800DAB0A3 /* JsonSchemaDecoder.swift */; };
+ 340019342EDF4D6800DAB0A3 /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019292EDF4D6800DAB0A3 /* ChatMessage.swift */; };
+ 340019352EDF4D6800DAB0A3 /* ToolNative.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019322EDF4D6800DAB0A3 /* ToolNative.swift */; };
+ 340019372EDF4D6800DAB0A3 /* ToolCallWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019312EDF4D6800DAB0A3 /* ToolCallWatcher.swift */; };
+ 340019382EDF4D6800DAB0A3 /* ChatOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3400192B2EDF4D6800DAB0A3 /* ChatOptions.swift */; };
+ 340019392EDF4D6800DAB0A3 /* ChatMessageContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3400192A2EDF4D6800DAB0A3 /* ChatMessageContent.swift */; };
+ 3400193A2EDF4D6800DAB0A3 /* ChatResponseNative.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3400192C2EDF4D6800DAB0A3 /* ChatResponseNative.swift */; };
+ 3400193B2EDF4D6800DAB0A3 /* ChatClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019282EDF4D6800DAB0A3 /* ChatClient.swift */; };
+ 3400193C2EDF4D6800DAB0A3 /* ChatResponseUpdateNative.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3400192D2EDF4D6800DAB0A3 /* ChatResponseUpdateNative.swift */; };
+ 3400193D2EDF4D6800DAB0A3 /* Cancellation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019272EDF4D6800DAB0A3 /* Cancellation.swift */; };
+ 3400193E2EDF4D6800DAB0A3 /* ChatTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3400192E2EDF4D6800DAB0A3 /* ChatTool.swift */; };
+ 340019BC2EE86CE300DAB0A3 /* AppleIntelligenceLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340019BB2EE86CE300DAB0A3 /* AppleIntelligenceLogger.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 340019272EDF4D6800DAB0A3 /* Cancellation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cancellation.swift; sourceTree = ""; };
+ 340019282EDF4D6800DAB0A3 /* ChatClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatClient.swift; sourceTree = ""; };
+ 340019292EDF4D6800DAB0A3 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = ""; };
+ 3400192A2EDF4D6800DAB0A3 /* ChatMessageContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessageContent.swift; sourceTree = ""; };
+ 3400192B2EDF4D6800DAB0A3 /* ChatOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatOptions.swift; sourceTree = ""; };
+ 3400192C2EDF4D6800DAB0A3 /* ChatResponseNative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatResponseNative.swift; sourceTree = ""; };
+ 3400192D2EDF4D6800DAB0A3 /* ChatResponseUpdateNative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatResponseUpdateNative.swift; sourceTree = ""; };
+ 3400192E2EDF4D6800DAB0A3 /* ChatTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTool.swift; sourceTree = ""; };
+ 340019302EDF4D6800DAB0A3 /* JsonSchemaDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonSchemaDecoder.swift; sourceTree = ""; };
+ 340019312EDF4D6800DAB0A3 /* ToolCallWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolCallWatcher.swift; sourceTree = ""; };
+ 340019322EDF4D6800DAB0A3 /* ToolNative.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolNative.swift; sourceTree = ""; };
+ 340019BB2EE86CE300DAB0A3 /* AppleIntelligenceLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleIntelligenceLogger.swift; sourceTree = ""; };
+ 34279C902EC421CC00583050 /* EssentialsAI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = EssentialsAI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 34279C8D2EC421CC00583050 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 34279C862EC421CC00583050 = {
+ isa = PBXGroup;
+ children = (
+ 340019BB2EE86CE300DAB0A3 /* AppleIntelligenceLogger.swift */,
+ 340019272EDF4D6800DAB0A3 /* Cancellation.swift */,
+ 340019282EDF4D6800DAB0A3 /* ChatClient.swift */,
+ 340019292EDF4D6800DAB0A3 /* ChatMessage.swift */,
+ 3400192A2EDF4D6800DAB0A3 /* ChatMessageContent.swift */,
+ 3400192B2EDF4D6800DAB0A3 /* ChatOptions.swift */,
+ 3400192C2EDF4D6800DAB0A3 /* ChatResponseNative.swift */,
+ 3400192D2EDF4D6800DAB0A3 /* ChatResponseUpdateNative.swift */,
+ 3400192E2EDF4D6800DAB0A3 /* ChatTool.swift */,
+ 340019302EDF4D6800DAB0A3 /* JsonSchemaDecoder.swift */,
+ 340019312EDF4D6800DAB0A3 /* ToolCallWatcher.swift */,
+ 340019322EDF4D6800DAB0A3 /* ToolNative.swift */,
+ 34279C912EC421CC00583050 /* Products */,
+ );
+ sourceTree = "";
+ };
+ 34279C912EC421CC00583050 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 34279C902EC421CC00583050 /* EssentialsAI.framework */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXHeadersBuildPhase section */
+ 34279C8B2EC421CC00583050 /* Headers */ = {
+ isa = PBXHeadersBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXHeadersBuildPhase section */
+
+/* Begin PBXNativeTarget section */
+ 34279C8F2EC421CC00583050 /* EssentialsAI */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 34279C992EC421CC00583050 /* Build configuration list for PBXNativeTarget "EssentialsAI" */;
+ buildPhases = (
+ 34279C8B2EC421CC00583050 /* Headers */,
+ 34279C8C2EC421CC00583050 /* Sources */,
+ 34279C8D2EC421CC00583050 /* Frameworks */,
+ 34279C8E2EC421CC00583050 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = EssentialsAI;
+ packageProductDependencies = (
+ );
+ productName = EssentialsAI;
+ productReference = 34279C902EC421CC00583050 /* EssentialsAI.framework */;
+ productType = "com.apple.product-type.framework";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 34279C872EC421CC00583050 /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 2610;
+ LastUpgradeCheck = 2610;
+ TargetAttributes = {
+ 34279C8F2EC421CC00583050 = {
+ CreatedOnToolsVersion = 26.1;
+ LastSwiftMigration = 2610;
+ };
+ };
+ };
+ buildConfigurationList = 34279C8A2EC421CC00583050 /* Build configuration list for PBXProject "EssentialsAI" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 34279C862EC421CC00583050;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 34279C912EC421CC00583050 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 34279C8F2EC421CC00583050 /* EssentialsAI */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 34279C8E2EC421CC00583050 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 34279C8C2EC421CC00583050 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 340019332EDF4D6800DAB0A3 /* JsonSchemaDecoder.swift in Sources */,
+ 340019342EDF4D6800DAB0A3 /* ChatMessage.swift in Sources */,
+ 340019352EDF4D6800DAB0A3 /* ToolNative.swift in Sources */,
+ 340019372EDF4D6800DAB0A3 /* ToolCallWatcher.swift in Sources */,
+ 340019382EDF4D6800DAB0A3 /* ChatOptions.swift in Sources */,
+ 340019392EDF4D6800DAB0A3 /* ChatMessageContent.swift in Sources */,
+ 3400193A2EDF4D6800DAB0A3 /* ChatResponseNative.swift in Sources */,
+ 3400193B2EDF4D6800DAB0A3 /* ChatClient.swift in Sources */,
+ 3400193C2EDF4D6800DAB0A3 /* ChatResponseUpdateNative.swift in Sources */,
+ 3400193D2EDF4D6800DAB0A3 /* Cancellation.swift in Sources */,
+ 3400193E2EDF4D6800DAB0A3 /* ChatTool.swift in Sources */,
+ 340019BC2EE86CE300DAB0A3 /* AppleIntelligenceLogger.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 34279C972EC421CC00583050 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 1;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ VERSIONING_SYSTEM = "apple-generic";
+ VERSION_INFO_PREFIX = "";
+ };
+ name = Debug;
+ };
+ 34279C982EC421CC00583050 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ CURRENT_PROJECT_VERSION = 1;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ VERSIONING_SYSTEM = "apple-generic";
+ VERSION_INFO_PREFIX = "";
+ };
+ name = Release;
+ };
+ 34279C9A2EC421CC00583050 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
+ BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEFINES_MODULE = YES;
+ DYLIB_COMPATIBILITY_VERSION = 1;
+ DYLIB_CURRENT_VERSION = 1;
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ ENABLE_MODULE_VERIFIER = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "@executable_path/../Frameworks",
+ "@loader_path/Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
+ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
+ PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.maui.EssentialsAI;
+ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator";
+ SUPPORTS_MACCATALYST = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_INSTALL_MODULE = YES;
+ SWIFT_INSTALL_OBJC_HEADER = YES;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2,3,7";
+ XROS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Debug;
+ };
+ 34279C9B2EC421CC00583050 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALLOW_TARGET_PLATFORM_SPECIALIZATION = YES;
+ BUILD_LIBRARY_FOR_DISTRIBUTION = YES;
+ CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEFINES_MODULE = YES;
+ DYLIB_COMPATIBILITY_VERSION = 1;
+ DYLIB_CURRENT_VERSION = 1;
+ DYLIB_INSTALL_NAME_BASE = "@rpath";
+ ENABLE_MODULE_VERIFIER = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
+ IPHONEOS_DEPLOYMENT_TARGET = 26.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "@executable_path/Frameworks",
+ "@loader_path/Frameworks",
+ );
+ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = (
+ "@executable_path/../Frameworks",
+ "@loader_path/Frameworks",
+ );
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MARKETING_VERSION = 1.0;
+ MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++";
+ MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20";
+ PRODUCT_BUNDLE_IDENTIFIER = com.microsoft.maui.EssentialsAI;
+ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
+ SDKROOT = auto;
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator";
+ SUPPORTS_MACCATALYST = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_INSTALL_MODULE = YES;
+ SWIFT_INSTALL_OBJC_HEADER = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ TARGETED_DEVICE_FAMILY = "1,2,3,7";
+ XROS_DEPLOYMENT_TARGET = 26.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 34279C8A2EC421CC00583050 /* Build configuration list for PBXProject "EssentialsAI" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 34279C972EC421CC00583050 /* Debug */,
+ 34279C982EC421CC00583050 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 34279C992EC421CC00583050 /* Build configuration list for PBXNativeTarget "EssentialsAI" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 34279C9A2EC421CC00583050 /* Debug */,
+ 34279C9B2EC421CC00583050 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 34279C872EC421CC00583050 /* Project object */;
+}
diff --git a/src/AI/src/AppleNative/EssentialsAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/src/AI/src/AppleNative/EssentialsAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000000..919434a6254f
--- /dev/null
+++ b/src/AI/src/AppleNative/EssentialsAI.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/src/AI/src/AppleNative/JsonSchemaDecoder.swift b/src/AI/src/AppleNative/JsonSchemaDecoder.swift
new file mode 100644
index 000000000000..2219d2a14a8e
--- /dev/null
+++ b/src/AI/src/AppleNative/JsonSchemaDecoder.swift
@@ -0,0 +1,119 @@
+import Foundation
+import FoundationModels
+
+class JsonSchemaDecoder {
+
+ /// Simple JSON Schema representation
+ private class JsonSchema: Codable {
+ var type: String?
+ var title: String?
+ var description: String?
+ var properties: [String: JsonSchema]?
+ var required: [String]?
+ var items: JsonSchema?
+ var additionalProperties: Bool?
+ var `enum`: [String]?
+ var minItems: Int?
+ var maxItems: Int?
+
+ static func parse(jsonString: String) throws -> JsonSchema? {
+ // Decode into an object
+ guard let data = jsonString.data(using: .utf8) else {
+ return nil
+ }
+ let jsonSchema = try JSONDecoder().decode(
+ JsonSchema.self,
+ from: data
+ )
+
+ // These seem to be required for tools to be called
+ jsonSchema.type = jsonSchema.type ?? "object"
+ if jsonSchema.type == "object" {
+ jsonSchema.properties = jsonSchema.properties ?? [:]
+ }
+
+ return jsonSchema
+ }
+ }
+
+ /// Some common JSON Schemas
+ static let StringGenerationSchema: GenerationSchema =
+ try! GenerationSchema(
+ root: DynamicGenerationSchema(type: String.self),
+ dependencies: []
+ )
+
+ /// Parse a string into a GenerationSchema
+ static func parse(_ jsonString: String) throws -> GenerationSchema? {
+ // Parse the JSON string
+ guard let jsonSchema = try JsonSchema.parse(jsonString: jsonString)
+ else { return nil }
+
+ // Convert into a DynamicJsonSchema
+ guard let dynamicSchema = toDynamicSchema(jsonSchema)
+ else { return nil }
+
+ // Get the final GenerationSchema
+ return try GenerationSchema(root: dynamicSchema, dependencies: [])
+ }
+
+ /// Convert the object representation of a JSON schema into a DynamicGenerationSchema
+ private static func toDynamicSchema(_ schema: JsonSchema)
+ -> DynamicGenerationSchema?
+ {
+ switch schema.type {
+ // Handle objects with properties
+ case "object":
+ guard let properties = schema.properties else { return nil }
+ let props = properties.compactMap { (name, value) in
+ parseJsonProperty(name, value, schema)
+ }
+ return DynamicGenerationSchema(
+ name: schema.title ?? "Object",
+ description: schema.description,
+ properties: props
+ )
+ // Handle arrays with items
+ case "array":
+ guard
+ let items = schema.items,
+ let itemSchema = toDynamicSchema(items)
+ else { return nil }
+ return DynamicGenerationSchema(
+ arrayOf: itemSchema,
+ minimumElements: schema.minItems,
+ maximumElements: schema.maxItems
+ )
+ // Handle primitive data types
+ case "string":
+ // Support enum values
+ if let enumValues = schema.enum, !enumValues.isEmpty {
+ return DynamicGenerationSchema(type: String.self, guides: [.anyOf(enumValues)])
+ }
+ // No enum values
+ return DynamicGenerationSchema(type: String.self)
+ case "integer": return DynamicGenerationSchema(type: Int.self)
+ case "number": return DynamicGenerationSchema(type: Double.self)
+ case "boolean": return DynamicGenerationSchema(type: Bool.self)
+ default: return nil
+ }
+ }
+
+ private static func parseJsonProperty(
+ _ propertyName: String,
+ _ value: JsonSchema,
+ _ parentSchema: JsonSchema
+ ) -> DynamicGenerationSchema.Property? {
+ guard let nestedSchema = toDynamicSchema(value) else {
+ return nil
+ }
+ let isRequired = parentSchema.required?.contains(propertyName) == true
+ return DynamicGenerationSchema.Property(
+ name: propertyName,
+ description: value.description,
+ schema: nestedSchema,
+ isOptional: !isRequired
+ )
+ }
+
+}
diff --git a/src/AI/src/AppleNative/ToolCallWatcher.swift b/src/AI/src/AppleNative/ToolCallWatcher.swift
new file mode 100644
index 000000000000..3fa530c8921d
--- /dev/null
+++ b/src/AI/src/AppleNative/ToolCallWatcher.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+final class ToolCallWatcher: Sendable {
+ let onToolCall: (@Sendable (String, String, String) -> Void)
+ let onToolResult: (@Sendable (String, String, String) -> Void)
+
+ init(
+ onToolCall: @escaping (@Sendable (String, String, String) -> Void),
+ onToolResult: @escaping (@Sendable (String, String, String) -> Void)
+ ) {
+ self.onToolCall = onToolCall
+ self.onToolResult = onToolResult
+ }
+
+ func notifyToolCall(id: String, name: String, arguments: String) {
+ onToolCall(id, name, arguments)
+ }
+
+ func notifyToolResult(id: String, name: String, result: String) {
+ onToolResult(id, name, result)
+ }
+}
diff --git a/src/AI/src/AppleNative/ToolNative.swift b/src/AI/src/AppleNative/ToolNative.swift
new file mode 100644
index 000000000000..3a6d6bb4268a
--- /dev/null
+++ b/src/AI/src/AppleNative/ToolNative.swift
@@ -0,0 +1,90 @@
+import Foundation
+import FoundationModels
+
+/// Swift wrapper that makes an AIToolBase conform to FoundationModels.Tool protocol
+final class ToolNative: Tool {
+ let tool: AIToolNative
+ let onToolCall: (@Sendable (String, String, String) -> Void)? // id, name, arguments
+ let onToolResult: (@Sendable (String, String, String) -> Void)? // id, name, result
+ let name: String
+ let description: String
+ let parameters: GenerationSchema
+ let output: GenerationSchema
+
+ init(
+ _ tool: AIToolNative,
+ _ onToolCall: (@Sendable (String, String, String) -> Void)?,
+ _ onToolResult: (@Sendable (String, String, String) -> Void)?
+ ) {
+ self.tool = tool
+ self.onToolCall = onToolCall
+ self.onToolResult = onToolResult
+
+ self.name = tool.name
+ self.description = tool.desc
+
+ // Parse the JSON schema for parameters
+ do {
+ self.parameters =
+ try JsonSchemaDecoder.parse(tool.argumentsSchema) ?? JsonSchemaDecoder.StringGenerationSchema
+ } catch {
+ self.parameters = JsonSchemaDecoder.StringGenerationSchema
+ }
+
+ // Parse the JSON schema for output
+ do {
+ self.output =
+ try JsonSchemaDecoder.parse(tool.outputSchema) ?? JsonSchemaDecoder.StringGenerationSchema
+ } catch {
+ self.output = JsonSchemaDecoder.StringGenerationSchema
+ }
+ }
+
+ func call(arguments: Arguments) async throws -> Output {
+ let argumentsJson = arguments.jsonString
+ let callId = arguments.id.map { String(describing: $0) } ?? UUID().uuidString
+
+ // Notify that tool is being called
+ onToolCall?(callId, name, argumentsJson)
+
+ // Call the C# tool
+ let resultJson: String = await withCheckedContinuation { continuation in
+ tool.call(arguments: argumentsJson) { result in
+ continuation.resume(returning: String(result))
+ }
+ }
+
+ // Notify that tool execution completed
+ onToolResult?(callId, name, resultJson)
+
+ return try Output(json: resultJson)
+ }
+
+ struct Arguments: ConvertibleFromGeneratedContent {
+ let id: GenerationID?
+ let jsonString: String
+ let generatedContent: GeneratedContent
+
+ init(_ content: GeneratedContent) throws {
+ self.id = content.id
+ self.jsonString = content.jsonString
+ self.generatedContent = content
+ }
+ }
+
+ struct Output: ConvertibleToGeneratedContent {
+ let promptRepresentation: Prompt
+ let generatedContent: GeneratedContent
+
+ init(json: String) throws {
+ self.promptRepresentation = .init({ json })
+
+ // Try to parse as JSON, otherwise use as plain text
+ do {
+ self.generatedContent = try .init(json: json)
+ } catch {
+ self.generatedContent = .init(json)
+ }
+ }
+ }
+}
diff --git a/src/AI/src/Essentials.AI/Essentials.AI.csproj b/src/AI/src/Essentials.AI/Essentials.AI.csproj
new file mode 100644
index 000000000000..0e921554ee4b
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Essentials.AI.csproj
@@ -0,0 +1,60 @@
+
+
+ netstandard2.1;netstandard2.0;$(_MauiDotNetTfm);$(MauiEssentialsAIPlatforms)
+ $(TargetFrameworks);$(_MauiPreviousDotNetTfm);$(MauiEssentialsAIPreviousPlatforms)
+ Microsoft.Maui.Essentials.AI
+ Microsoft.Maui.Essentials.AI
+ true
+ true
+ true
+ enable
+ enable
+ true
+
+
+
+ true
+ Microsoft.Maui.Essentials.AI
+ $(DefaultPackageTags);essentials;ai
+ .NET Multi-platform App UI (.NET MAUI) is a cross-platform framework for creating native mobile and desktop apps with C# and XAML. This package contains a collection of cross-platform APIs for working with device AI and local models.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EssentialsAI
+
+
+
+
+
+
+
+
+
+ <_FilesUsedForIntellisense Include="..\..\..\..\artifacts\obj\Essentials.AI\$(Configuration)\$(TargetFramework)\iOS\**\*.cs" Link="Platform\MaciOS\%(RecursiveDir)%(Filename)%(Extension)" Condition="Exists('..\..\..\..\artifacts\obj\Essentials.AI\$(Configuration)\$(TargetFramework)\iOS\ObjCRuntime\Messaging.g.cs')" />
+
+
+ <_FilesUsedForIntellisense Include="..\..\..\..\artifacts\obj\Essentials.AI\$(Configuration)\$(TargetFramework)\MacCatalyst\**\*.cs" Link="Platform\MaciOS\%(RecursiveDir)%(Filename)%(Extension)" Condition="Exists('..\..\..\..\artifacts\obj\Essentials.AI\$(Configuration)\$(TargetFramework)\MacCatalyst\ObjCRuntime\Messaging.g.cs')" />
+
+
+ <_FilesUsedForIntellisense Include="Platform\MaciOS\StructsAndEnums.cs" />
+
+
+
+
+
+
+
+
diff --git a/src/AI/src/Essentials.AI/Extensions.cs b/src/AI/src/Essentials.AI/Extensions.cs
new file mode 100644
index 000000000000..944dfbf02dcd
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Extensions.cs
@@ -0,0 +1,33 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Maui.Essentials.AI;
+
+public static class Extensions
+{
+
+#if IOS || MACCATALYST || MACOS
+ public static IServiceCollection AddAppleIntelligenceChatClient(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ {
+ _ = services ?? throw new ArgumentNullException(nameof(services));
+
+ services.Add(new ServiceDescriptor(typeof(AppleIntelligenceChatClient), typeof(AppleIntelligenceChatClient), lifetime));
+ services.Add(new ServiceDescriptor(typeof(IChatClient), provider => provider.GetRequiredService(), lifetime));
+
+ return services;
+ }
+#endif
+
+ public static IServiceCollection AddPlatformChatClient(this IServiceCollection services, ServiceLifetime lifetime = ServiceLifetime.Singleton)
+ {
+ _ = services ?? throw new ArgumentNullException(nameof(services));
+
+#if IOS || MACCATALYST || MACOS
+ services.AddAppleIntelligenceChatClient(lifetime);
+#endif
+
+ return services;
+ }
+}
diff --git a/src/AI/src/Essentials.AI/Platform/ChatClientBase.cs b/src/AI/src/Essentials.AI/Platform/ChatClientBase.cs
new file mode 100644
index 000000000000..36f103808a77
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/ChatClientBase.cs
@@ -0,0 +1,248 @@
+using System.Diagnostics;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Microsoft.Maui.Essentials.AI;
+
+///
+/// Provides a base class for implementations with built-in logging support.
+///
+///
+///
+/// When the employed enables , the contents of
+/// chat messages and options are logged. These messages and options may contain sensitive application data.
+/// is disabled by default and should never be enabled in a production environment.
+/// Messages and options are not logged at other logging levels.
+///
+///
+public abstract partial class ChatClientBase : IChatClient
+{
+ ///
+ /// Lazily-initialized metadata describing the implementation.
+ ///
+ private ChatClientMetadata? _metadata;
+
+ ///
+ /// The logger to use for logging information about chat operations.
+ ///
+ private readonly ILogger _logger;
+
+ ///
+ /// The to use for serialization of state written to the logger.
+ ///
+ private JsonSerializerOptions _jsonSerializerOptions;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// An optional instance for logging chat operations.
+ internal ChatClientBase(ILogger? logger)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ _jsonSerializerOptions = AIJsonUtilities.DefaultOptions;
+ }
+
+ ///
+ /// Gets the logger instance used by this client.
+ ///
+ internal ILogger Logger => _logger;
+
+ /// Gets or sets JSON serialization options to use when serializing logging data.
+ public JsonSerializerOptions JsonSerializerOptions
+ {
+ get => _jsonSerializerOptions;
+ set => _jsonSerializerOptions = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ ///
+ /// Gets the provider name for metadata. Override in derived classes.
+ ///
+ internal abstract string ProviderName { get; }
+
+ ///
+ /// Gets the default model ID for metadata. Override in derived classes.
+ ///
+ internal abstract string DefaultModelId { get; }
+
+ ///
+ public abstract Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ public abstract IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default);
+
+ ///
+ object? IChatClient.GetService(Type serviceType, object? serviceKey)
+ {
+ if (serviceType is null)
+ {
+ throw new ArgumentNullException(nameof(serviceType));
+ }
+
+ if (serviceKey is not null)
+ {
+ return null;
+ }
+
+ if (serviceType == typeof(ChatClientMetadata))
+ {
+ return _metadata ??= new ChatClientMetadata(
+ providerName: ProviderName,
+ defaultModelId: DefaultModelId);
+ }
+
+ if (serviceType.IsInstanceOfType(this))
+ {
+ return this;
+ }
+
+ return null;
+ }
+
+ ///
+ void IDisposable.Dispose()
+ {
+ // Nothing to dispose by default. Override if needed.
+ }
+
+ internal void LogMethodInvoked(string methodName, IEnumerable messages, ChatOptions? options)
+ {
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ LogInvokedSensitive(methodName, AsJson(messages), AsJson(options), AsJson(((IChatClient)this).GetService()));
+ }
+ else if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogInvoked(methodName);
+ }
+ }
+
+ internal void LogMethodCompleted(string methodName, ChatResponse? response = null)
+ {
+ if (_logger.IsEnabled(LogLevel.Trace) && response is not null)
+ {
+ LogCompletedSensitive(methodName, AsJson(response));
+ }
+ else if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogCompleted(methodName);
+ }
+ }
+
+ internal void LogMethodCanceled(string methodName)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogInvocationCanceled(methodName);
+ }
+ }
+
+ internal void LogMethodFailed(string methodName, Exception error)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogInvocationFailed(methodName, error);
+ }
+ }
+
+ internal void LogStreamingCompleted(string methodName)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogCompleted(methodName);
+ }
+ }
+
+ internal void LogStreamingUpdate(string methodName, ChatResponseUpdate update)
+ {
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ LogStreamingUpdateSensitive(methodName, AsJson(update));
+ }
+ }
+
+ internal void LogFunctionInvoking(string methodName, string functionName, string callId, string? arguments = null)
+ {
+ if (_logger.IsEnabled(LogLevel.Trace) && arguments is not null)
+ {
+ LogToolInvokedSensitive(methodName, functionName, callId, arguments);
+ }
+ else if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogToolInvoked(methodName, functionName, callId);
+ }
+ }
+
+ internal void LogFunctionInvocationCompleted(string methodName, string callId, string? result = null)
+ {
+ if (_logger.IsEnabled(LogLevel.Trace) && result is not null)
+ {
+ LogToolInvocationCompletedSensitive(methodName, callId, result);
+ }
+ else if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ LogToolInvocationCompleted(methodName, callId);
+ }
+ }
+
+ internal string AsJson(T value)
+ {
+ try
+ {
+ if (_jsonSerializerOptions.TryGetTypeInfo(typeof(T), out var typeInfo) ||
+ AIJsonUtilities.DefaultOptions.TryGetTypeInfo(typeof(T), out typeInfo))
+ {
+ return JsonSerializer.Serialize(value, typeInfo);
+ }
+ }
+ catch
+ {
+ // If we fail to serialize, just fall through to returning "{}".
+ }
+
+ return "{}";
+ }
+
+ // Method invocation logging
+ [LoggerMessage(LogLevel.Debug, "{MethodName} invoked.")]
+ private partial void LogInvoked(string methodName);
+
+ [LoggerMessage(LogLevel.Trace, "{MethodName} invoked: {Messages}. Options: {ChatOptions}. Metadata: {ChatClientMetadata}.")]
+ private partial void LogInvokedSensitive(string methodName, string messages, string chatOptions, string chatClientMetadata);
+
+ [LoggerMessage(LogLevel.Debug, "{MethodName} completed.")]
+ private partial void LogCompleted(string methodName);
+
+ [LoggerMessage(LogLevel.Trace, "{MethodName} completed: {ChatResponse}.")]
+ private partial void LogCompletedSensitive(string methodName, string chatResponse);
+
+ [LoggerMessage(LogLevel.Trace, "{MethodName} received streaming update: {ChatResponseUpdate}")]
+ private partial void LogStreamingUpdateSensitive(string methodName, string chatResponseUpdate);
+
+ [LoggerMessage(LogLevel.Debug, "{MethodName} canceled.")]
+ private partial void LogInvocationCanceled(string methodName);
+
+ [LoggerMessage(LogLevel.Error, "{MethodName} failed.")]
+ private partial void LogInvocationFailed(string methodName, Exception error);
+
+ // Tool call logging
+ [LoggerMessage(LogLevel.Debug, "{MethodName} received tool call: {ToolName} (ID: {ToolCallId})")]
+ private partial void LogToolInvoked(string methodName, string toolName, string toolCallId);
+
+ [LoggerMessage(LogLevel.Trace, "{MethodName} received tool call: {ToolName} (ID: {ToolCallId}) with arguments: {Arguments}")]
+ private partial void LogToolInvokedSensitive(string methodName, string toolName, string toolCallId, string arguments);
+
+ [LoggerMessage(LogLevel.Debug, "{MethodName} received tool result for call ID: {ToolCallId}")]
+ private partial void LogToolInvocationCompleted(string methodName, string toolCallId);
+
+ [LoggerMessage(LogLevel.Trace, "{MethodName} received tool result for call ID: {ToolCallId}: {Result}")]
+ private partial void LogToolInvocationCompletedSensitive(string methodName, string toolCallId, string result);
+}
diff --git a/src/AI/src/Essentials.AI/Platform/JsonStreamChunker.cs b/src/AI/src/Essentials.AI/Platform/JsonStreamChunker.cs
new file mode 100644
index 000000000000..fac9e9b172c5
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/JsonStreamChunker.cs
@@ -0,0 +1,1532 @@
+using System.Text;
+using System.Text.Json;
+
+namespace Microsoft.Maui.Essentials.AI;
+
+///
+/// Converts complete JSON objects (from an AI model that post-processes its output) back into
+/// streaming chunks. The AI model receives progressive JSON internally but outputs complete
+/// valid JSON objects each time, sometimes with reordered properties.
+///
+///
+///
+/// Problem: AI models may output complete JSON objects at each step, but we want to
+/// stream partial output to the user for better UX.
+///
+///
+/// Solution: This chunker compares successive complete JSON snapshots and emits only
+/// the delta (new/changed content) as streaming chunks.
+///
+///
+/// Key Design Principles:
+///
+/// - Growable types (strings, arrays, objects) are potentially partial until proven complete
+/// - Non-growable types (numbers, bools, null) are always emitted complete immediately
+/// - Multiple new growables at same level → add to pending, wait for next chunk to disambiguate
+/// - Use path-based tracking (e.g., "days[0].title") not position-based
+/// - Properties only grow or stay same - never shrink or disappear
+///
+///
+///
+/// See json-stream-chunker-design.md for full algorithm documentation.
+///
+///
+internal sealed class JsonStreamChunker : StreamChunkerBase
+{
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+ // STATE TRACKING
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+
+ /// Flattened path→value dictionary from the previous chunk.
+ private Dictionary? _prevState;
+
+ /// Path of the currently open string (no closing quote emitted yet).
+ private string? _openStringPath;
+
+ /// Tracks emitted string values by path for extension detection.
+ private readonly Dictionary _emittedStrings = new();
+
+ /// Strings waiting for next chunk to determine which is the active/partial one.
+ private readonly Dictionary _pendingStrings = new();
+
+ /// Containers (arrays/objects) waiting for next chunk to see if they grow.
+ /// Key is path, Value is true for array, false for object.
+ private readonly Dictionary _pendingContainers = new();
+
+ /// All paths we've already output (for comma management).
+ private readonly HashSet _emittedPaths = new();
+
+ /// Stack of open containers (objects/arrays) for proper closing.
+ private readonly Stack<(string Path, bool IsArray)> _openStructures = new();
+
+ /// Represents a JSON value with its kind and content.
+ private record struct JsonValue(JsonValueKind Kind, string? StringValue, string? RawValue);
+
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+ // HELPER: Is this a "growable" value type?
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Returns true if the JSON value kind is a "growable" type (string, array, object).
+ /// Growable types may be partial and need special handling.
+ ///
+ private static bool IsGrowable(JsonValueKind kind) =>
+ kind == JsonValueKind.String || kind == JsonValueKind.Array || kind == JsonValueKind.Object;
+
+ ///
+ /// Closes any currently open string by emitting the closing quote.
+ ///
+ private void CloseOpenString(StringBuilder sb)
+ {
+ if (_openStringPath != null)
+ {
+ sb.Append('"');
+ _openStringPath = null;
+ }
+ }
+
+ ///
+ /// Emits and removes any pending items that are children of the given path.
+ /// Used when we know a subtree is complete (e.g., moving to next array item in first chunk).
+ ///
+ private void EmitPendingItemsUnder(StringBuilder sb, string parentPath)
+ {
+ // Find pending strings under this path
+ var stringsToEmit = _pendingStrings
+ .Where(kvp => kvp.Key.StartsWith(parentPath + ".", StringComparison.Ordinal) ||
+ kvp.Key.StartsWith(parentPath + "[", StringComparison.Ordinal))
+ .OrderBy(kvp => kvp.Key)
+ .ToList();
+
+ foreach (var (path, value) in stringsToEmit)
+ {
+ EmitPendingString(sb, path, value, keepOpen: false);
+ _pendingStrings.Remove(path);
+ }
+
+ // Find pending containers under this path
+ var containersToEmit = _pendingContainers
+ .Where(kvp => kvp.Key.StartsWith(parentPath + ".", StringComparison.Ordinal) ||
+ kvp.Key.StartsWith(parentPath + "[", StringComparison.Ordinal))
+ .OrderBy(kvp => kvp.Key)
+ .ToList();
+
+ foreach (var (path, isArray) in containersToEmit)
+ {
+ var (containerParent, propName) = SplitPath(path);
+ CloseStructuresDownTo(sb, containerParent);
+
+ if (HasEmittedSiblingAt(containerParent))
+ sb.Append(',');
+
+ sb.Append("\"" + Escape(propName) + "\":");
+ sb.Append(isArray ? "[]" : "{}");
+ _emittedPaths.Add(path);
+ _pendingContainers.Remove(path);
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+ // PUBLIC API
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Processes a complete JSON snapshot and returns a streaming chunk representing the delta.
+ ///
+ /// A complete, valid JSON object representing the current state.
+ /// A string chunk to emit (may be empty if no output yet). Concatenating all chunks yields valid JSON.
+ ///
+ /// Call this method for each complete JSON object received from the AI model.
+ /// The chunker maintains state between calls to track what has been emitted.
+ ///
+ public override string Process(string completeJson)
+ {
+ if (string.IsNullOrWhiteSpace(completeJson))
+ return string.Empty;
+
+ JsonDocument doc;
+ try
+ {
+ doc = JsonDocument.Parse(completeJson);
+ }
+ catch
+ {
+ return string.Empty;
+ }
+
+ using (doc)
+ {
+ var currState = FlattenJson(doc.RootElement, "");
+
+ string result;
+ if (_prevState == null)
+ {
+ // First chunk - emit structure with strings potentially open
+ result = ProcessFirstChunk(currState, doc.RootElement);
+ }
+ else
+ {
+ // Subsequent chunk - compare and emit deltas
+ result = ProcessSubsequentChunk(currState, doc.RootElement);
+ }
+
+ _prevState = currState;
+ return result;
+ }
+ }
+
+ ///
+ /// Flushes any remaining state and closes all open structures.
+ ///
+ /// Final chunk to complete the JSON output (may be empty).
+ ///
+ /// Must be called after all JSON snapshots have been processed to properly close
+ /// any pending strings and open containers (objects/arrays).
+ ///
+ public override string Flush()
+ {
+ var sb = new StringBuilder();
+
+ // Emit any pending strings that never got disambiguated
+ if (_pendingStrings.Count > 0)
+ {
+ foreach (var (path, value) in _pendingStrings.OrderBy(p => p.Key))
+ EmitPendingString(sb, path, value, keepOpen: false);
+ _pendingStrings.Clear();
+ }
+
+ // Emit any pending containers that never got disambiguated
+ if (_pendingContainers.Count > 0)
+ {
+ foreach (var (path, isArray) in _pendingContainers.OrderBy(p => p.Key))
+ {
+ var (parentPath, propName) = SplitPath(path);
+ CloseStructuresDownTo(sb, parentPath);
+
+ if (HasEmittedSiblingAt(parentPath))
+ sb.Append(',');
+
+ sb.Append("\"" + Escape(propName) + "\":");
+ sb.Append(isArray ? "[]" : "{}");
+ _emittedPaths.Add(path);
+ }
+ _pendingContainers.Clear();
+ }
+
+ // Close any open string
+ CloseOpenString(sb);
+
+ // Close all open structures (objects/arrays)
+ while (_openStructures.Count > 0)
+ {
+ var (_, isArray) = _openStructures.Pop();
+ sb.Append(isArray ? ']' : '}');
+ }
+
+ return sb.ToString();
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+ // FIRST CHUNK PROCESSING
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Processes the first JSON chunk - emits initial structure with strings potentially open.
+ ///
+ private string ProcessFirstChunk(Dictionary state, JsonElement elem)
+ {
+ var sb = new StringBuilder();
+ sb.Append('{');
+ _openStructures.Push(("", false));
+ _emittedPaths.Add("");
+
+ // Group strings by parent to determine if we have multiple at same level (ambiguous)
+ var stringsByParent = GroupStringsByParent(state);
+ EmitStructure(sb, elem, "", state, stringsByParent);
+
+ return sb.ToString();
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+ // SUBSEQUENT CHUNK PROCESSING
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Processes subsequent chunks - compares with previous state and emits deltas.
+ ///
+ private string ProcessSubsequentChunk(Dictionary currState, JsonElement currElem)
+ {
+ var sb = new StringBuilder();
+
+ // Step 1: Handle any currently open string (check if it changed, has new sibling, or parent changed)
+ if (_openStringPath != null)
+ HandleOpenString(sb, currState);
+
+ // Step 2: Resolve any pending items (strings and containers) from previous chunk
+ if (_pendingStrings.Count > 0 || _pendingContainers.Count > 0)
+ ResolvePendingItems(sb, currState, currElem);
+
+ // Step 3: Process new content (new properties, new array items)
+ ProcessNewContent(sb, _prevState!, currState, currElem, "");
+
+ return sb.ToString();
+ }
+
+ ///
+ /// Handles an open string from the previous chunk.
+ /// Determines if string is complete (has new sibling or parent changed) or still growing.
+ ///
+ ///
+ /// String completion rules:
+ /// 1. New sibling at same level → string is complete (close it)
+ /// 2. Parent-level change (e.g., new array item) → string is complete (close it)
+ /// 3. Value changed but no sibling/parent change → emit extension, keep open
+ /// 4. Value unchanged → string is complete (close it)
+ ///
+ private void HandleOpenString(StringBuilder sb, Dictionary currState)
+ {
+ if (_openStringPath == null)
+ return;
+
+ var emitted = _emittedStrings.GetValueOrDefault(_openStringPath, "");
+ var curr = currState.TryGetValue(_openStringPath, out var currVal) && currVal.Kind == JsonValueKind.String
+ ? currVal.StringValue ?? ""
+ : "";
+
+ var parentPath = GetParentPath(_openStringPath);
+ bool hasNewSibling = HasNewSiblingAt(parentPath, _prevState!, currState);
+ bool hasParentChange = HasParentLevelChange(_openStringPath, _prevState!, currState);
+
+ if (hasNewSibling || hasParentChange)
+ {
+ // String is complete - emit any remaining extension and close
+ if (curr != emitted && curr.Length > emitted.Length)
+ sb.Append(Escape(curr.Substring(emitted.Length)));
+ sb.Append('"');
+ _openStringPath = null;
+ }
+ else if (curr != emitted)
+ {
+ // String changed but no completion signal - emit extension, keep open
+ if (curr.Length > emitted.Length)
+ sb.Append(Escape(curr.Substring(emitted.Length)));
+ _emittedStrings[_openStringPath] = curr;
+ }
+ else
+ {
+ // Value unchanged - string is complete
+ sb.Append('"');
+ _openStringPath = null;
+ }
+ }
+
+ ///
+ /// Resolves pending items (strings and containers) from previous chunk by comparing current values.
+ ///
+ ///
+ /// When we had multiple new growables at the same level, we couldn't tell which was partial.
+ /// Now we can compare: items that didn't change are complete, the one that changed is the active one.
+ ///
+ private void ResolvePendingItems(StringBuilder sb, Dictionary currState, JsonElement currElem)
+ {
+ // Determine which pending item changed
+ string? changedStringPath = null;
+ string? changedStringValue = null;
+ string? changedContainerPath = null;
+ var completeStrings = new List<(string Path, string Value)>();
+ var completeContainers = new List<(string Path, bool IsArray)>();
+
+ // Check pending strings
+ foreach (var (path, storedValue) in _pendingStrings)
+ {
+ if (!currState.TryGetValue(path, out var currVal) || currVal.Kind != JsonValueKind.String)
+ continue;
+
+ var currValue = currVal.StringValue ?? "";
+ if (currValue == storedValue)
+ {
+ // Unchanged - this one is complete
+ completeStrings.Add((path, currValue));
+ }
+ else
+ {
+ // Changed - this is the active/partial string
+ changedStringPath = path;
+ changedStringValue = currValue;
+ }
+ }
+
+ // Check pending containers
+ foreach (var (path, isArray) in _pendingContainers)
+ {
+ bool containerChanged = HasContainerGrown(path, _prevState!, currState);
+ if (containerChanged)
+ {
+ // Container grew - it's the active one
+ changedContainerPath = path;
+ }
+ else
+ {
+ // Unchanged - complete
+ completeContainers.Add((path, isArray));
+ }
+ }
+
+ // Emit all complete items first
+ // Sort by path to maintain consistent order
+ var allComplete = completeStrings.Select(s => (s.Path, IsString: true, Value: s.Value, IsArray: false))
+ .Concat(completeContainers.Select(c => (c.Path, IsString: false, Value: "", c.IsArray)))
+ .OrderBy(x => x.Path)
+ .ToList();
+
+ foreach (var item in allComplete)
+ {
+ if (item.IsString)
+ {
+ EmitPendingString(sb, item.Path, item.Value, keepOpen: false);
+ }
+ else
+ {
+ EmitPendingContainer(sb, item.Path, item.IsArray, currElem, currState, complete: true);
+ }
+ }
+
+ // Emit the changed item (potentially still open/growing)
+ if (changedStringPath != null && changedStringValue != null)
+ {
+ EmitPendingString(sb, changedStringPath, changedStringValue, keepOpen: true);
+
+ // Check if it should be closed due to sibling
+ if (HasNewSiblingAt(GetParentPath(changedStringPath), _prevState!, currState))
+ {
+ sb.Append('"');
+ _openStringPath = null;
+ }
+ }
+ else if (changedContainerPath != null)
+ {
+ bool isArray = _pendingContainers[changedContainerPath];
+ EmitPendingContainer(sb, changedContainerPath, isArray, currElem, currState, complete: false);
+ }
+
+ // Remove only the items that were pending when we entered this method.
+ // Don't clear everything - EmitPendingContainer may have added NEW pending items
+ // when processing nested content.
+ foreach (var (path, _) in completeStrings)
+ _pendingStrings.Remove(path);
+ if (changedStringPath != null)
+ _pendingStrings.Remove(changedStringPath);
+
+ foreach (var (path, _) in completeContainers)
+ _pendingContainers.Remove(path);
+ if (changedContainerPath != null)
+ _pendingContainers.Remove(changedContainerPath);
+ }
+
+ ///
+ /// Checks if a container (array or object) has grown since previous state.
+ ///
+ private bool HasContainerGrown(string path, Dictionary prevState, Dictionary currState)
+ {
+ // Check if any children were added or changed
+ var prevChildren = prevState.Keys.Where(k => k.StartsWith(path + ".") || k.StartsWith(path + "[")).ToHashSet();
+ var currChildren = currState.Keys.Where(k => k.StartsWith(path + ".") || k.StartsWith(path + "[")).ToHashSet();
+
+ // Container grew if there are new keys that weren't in prevChildren
+ // This handles the case where days[0] (empty object) becomes days[0].activities[0].title
+ return !currChildren.SetEquals(prevChildren);
+ }
+
+ ///
+ /// Emits a pending container (array or object) that has been resolved.
+ ///
+ private void EmitPendingContainer(StringBuilder sb, string path, bool isArray, JsonElement currElem, Dictionary currState, bool complete)
+ {
+ var (parentPath, propName) = SplitPath(path);
+ CloseStructuresDownTo(sb, parentPath);
+
+ if (HasEmittedSiblingAt(parentPath))
+ sb.Append(',');
+
+ // Get the actual element at this path
+ var elem = GetElementAtPath(currElem, path);
+
+ sb.Append("\"" + Escape(propName) + "\":");
+ _emittedPaths.Add(path);
+
+ if (isArray)
+ {
+ sb.Append('[');
+ if (complete)
+ {
+ // Emit complete array content
+ if (elem.HasValue && elem.Value.ValueKind == JsonValueKind.Array)
+ EmitCompleteArrayContent(sb, elem.Value, path);
+ sb.Append(']');
+ }
+ else
+ {
+ // Array is still growing - push to open structures
+ _openStructures.Push((path, true));
+ if (elem.HasValue && elem.Value.ValueKind == JsonValueKind.Array)
+ EmitArrayContent(sb, elem.Value, path);
+ }
+ }
+ else
+ {
+ sb.Append('{');
+ if (complete)
+ {
+ // Emit complete object content
+ if (elem.HasValue && elem.Value.ValueKind == JsonValueKind.Object)
+ EmitCompleteObjectContent(sb, elem.Value, path);
+ sb.Append('}');
+ }
+ else
+ {
+ // Object is still growing - push to open structures
+ _openStructures.Push((path, false));
+ if (elem.HasValue && elem.Value.ValueKind == JsonValueKind.Object)
+ EmitObjectContent(sb, elem.Value, path);
+ }
+ }
+ }
+
+ ///
+ /// Emits complete array content (all closed).
+ ///
+ private void EmitCompleteArrayContent(StringBuilder sb, JsonElement elem, string path)
+ {
+ int idx = 0;
+ foreach (var item in elem.EnumerateArray())
+ {
+ if (idx > 0)
+ sb.Append(',');
+
+ var itemPath = path + "[" + idx + "]";
+ _emittedPaths.Add(itemPath);
+ EmitCompleteValue(sb, item, itemPath);
+ idx++;
+ }
+ }
+
+ ///
+ /// Emits complete object content (all closed).
+ ///
+ private void EmitCompleteObjectContent(StringBuilder sb, JsonElement elem, string path)
+ {
+ bool isFirst = true;
+ foreach (var prop in elem.EnumerateObject())
+ {
+ if (!isFirst)
+ sb.Append(',');
+ isFirst = false;
+
+ var propPath = CombinePath(path, prop.Name);
+ sb.Append("\"" + Escape(prop.Name) + "\":");
+ _emittedPaths.Add(propPath);
+ EmitCompleteValue(sb, prop.Value, propPath);
+ }
+ }
+
+ ///
+ /// Emits a complete (closed) JSON value at the given path.
+ /// Used for values that are fully resolved and should not be left open.
+ ///
+ private void EmitCompleteValue(StringBuilder sb, JsonElement elem, string path)
+ {
+ switch (elem.ValueKind)
+ {
+ case JsonValueKind.Object:
+ sb.Append('{');
+ EmitCompleteObjectContent(sb, elem, path);
+ sb.Append('}');
+ break;
+ case JsonValueKind.Array:
+ sb.Append('[');
+ EmitCompleteArrayContent(sb, elem, path);
+ sb.Append(']');
+ break;
+ case JsonValueKind.String:
+ sb.Append('"');
+ sb.Append(Escape(elem.GetString() ?? ""));
+ sb.Append('"');
+ _emittedStrings[path] = elem.GetString() ?? "";
+ break;
+ default:
+ sb.Append(elem.GetRawText());
+ break;
+ }
+ }
+
+ ///
+ /// Gets a JSON element at the specified path.
+ ///
+ private static JsonElement? GetElementAtPath(JsonElement root, string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ return root;
+
+ var current = root;
+ var segments = ParsePath(path);
+
+ foreach (var segment in segments)
+ {
+ if (segment.StartsWith("[") && segment.EndsWith("]"))
+ {
+ // Array index
+ var idxStr = segment.Substring(1, segment.Length - 2);
+ if (int.TryParse(idxStr, out var idx) && current.ValueKind == JsonValueKind.Array && idx < current.GetArrayLength())
+ current = current[idx];
+ else
+ return null;
+ }
+ else
+ {
+ // Property name
+ if (current.ValueKind == JsonValueKind.Object && current.TryGetProperty(segment, out var prop))
+ current = prop;
+ else
+ return null;
+ }
+ }
+
+ return current;
+ }
+
+ ///
+ /// Parses a path into segments.
+ ///
+ private static List ParsePath(string path)
+ {
+ var segments = new List();
+ var current = new StringBuilder();
+
+ for (int i = 0; i < path.Length; i++)
+ {
+ char c = path[i];
+ if (c == '.')
+ {
+ if (current.Length > 0)
+ {
+ segments.Add(current.ToString());
+ current.Clear();
+ }
+ }
+ else if (c == '[')
+ {
+ if (current.Length > 0)
+ {
+ segments.Add(current.ToString());
+ current.Clear();
+ }
+ // Find matching ]
+ int end = path.IndexOf(']', i);
+ if (end > i)
+ {
+ segments.Add(path.Substring(i, end - i + 1));
+ i = end;
+ }
+ }
+ else
+ {
+ current.Append(c);
+ }
+ }
+
+ if (current.Length > 0)
+ segments.Add(current.ToString());
+
+ return segments;
+ }
+
+ ///
+ /// Recursively processes new content by comparing previous and current state.
+ ///
+ private void ProcessNewContent(StringBuilder sb, Dictionary prevState,
+ Dictionary currState, JsonElement currElem, string path)
+ {
+ if (currElem.ValueKind == JsonValueKind.Object)
+ ProcessObjectChanges(sb, prevState, currState, currElem, path);
+ else if (currElem.ValueKind == JsonValueKind.Array)
+ ProcessArrayChanges(sb, prevState, currState, currElem, path);
+ }
+
+ ///
+ /// Processes changes within an object - identifies new properties and recurses into existing ones.
+ ///
+ private void ProcessObjectChanges(StringBuilder sb, Dictionary prevState,
+ Dictionary currState, JsonElement currElem, string path)
+ {
+ var prevProps = GetPropertiesAtPath(prevState, path);
+ var newStringProps = new List<(string Name, JsonElement Value, string Path)>();
+ var newContainerProps = new List<(string Name, JsonElement Value, string Path, bool IsArray)>();
+ var newNonGrowableProps = new List<(string Name, JsonElement Value, string Path)>();
+ int newGrowablesCount = 0;
+
+ foreach (var prop in currElem.EnumerateObject())
+ {
+ var propPath = CombinePath(path, prop.Name);
+
+ if (prevProps.Contains(prop.Name))
+ {
+ // Existing property - recurse into non-strings (strings handled elsewhere)
+ if (prop.Value.ValueKind != JsonValueKind.String)
+ ProcessNewContent(sb, prevState, currState, prop.Value, propPath);
+ }
+ else
+ {
+ // Skip if already emitted (e.g., during pending resolution)
+ if (_emittedPaths.Contains(propPath))
+ continue;
+
+ // New property - categorize it
+ switch (prop.Value.ValueKind)
+ {
+ case JsonValueKind.String:
+ newGrowablesCount++;
+ newStringProps.Add((prop.Name, prop.Value, propPath));
+ break;
+ case JsonValueKind.Array:
+ newGrowablesCount++;
+ newContainerProps.Add((prop.Name, prop.Value, propPath, true));
+ break;
+ case JsonValueKind.Object:
+ newGrowablesCount++;
+ newContainerProps.Add((prop.Name, prop.Value, propPath, false));
+ break;
+ default:
+ newNonGrowableProps.Add((prop.Name, prop.Value, propPath));
+ break;
+ }
+ }
+ }
+
+ // Emit non-growable properties immediately
+ foreach (var (name, value, propPath) in newNonGrowableProps)
+ EmitNewProperty(sb, name, value, propPath, path);
+
+ // Handle new growable properties
+ if (newGrowablesCount == 1)
+ {
+ // Only one growable - emit it appropriately
+ if (newStringProps.Count == 1 && _openStringPath == null)
+ {
+ var (name, value, propPath) = newStringProps[0];
+ EmitNewStringProperty(sb, name, value.GetString() ?? "", propPath, path, keepOpen: true);
+ }
+ else if (newStringProps.Count == 1)
+ {
+ // Already have open string - add to pending
+ foreach (var (_, value, propPath) in newStringProps)
+ _pendingStrings[propPath] = value.GetString() ?? "";
+ }
+ else if (newContainerProps.Count == 1)
+ {
+ var (name, value, propPath, isArray) = newContainerProps[0];
+ EmitNewProperty(sb, name, value, propPath, path);
+ }
+ }
+ else if (newGrowablesCount > 1)
+ {
+ // Multiple growables - add all to pending
+ foreach (var (_, value, propPath) in newStringProps)
+ _pendingStrings[propPath] = value.GetString() ?? "";
+ foreach (var (_, _, propPath, isArray) in newContainerProps)
+ _pendingContainers[propPath] = isArray;
+ }
+ }
+
+ ///
+ /// Processes changes within an array - identifies new items and recurses into existing ones.
+ ///
+ private void ProcessArrayChanges(StringBuilder sb, Dictionary prevState,
+ Dictionary currState, JsonElement currElem, string path)
+ {
+ int prevCount = GetArrayCountAtPath(prevState, path);
+ var items = currElem.EnumerateArray().ToList();
+
+ for (int idx = 0; idx < items.Count; idx++)
+ {
+ var item = items[idx];
+ var itemPath = path + "[" + idx + "]";
+
+ if (idx < prevCount)
+ {
+ // Existing item - recurse into non-strings
+ if (item.ValueKind != JsonValueKind.String)
+ ProcessNewContent(sb, prevState, currState, item, itemPath);
+ }
+ else
+ {
+ // New array item - skip if already emitted (e.g., during pending resolution)
+ if (_emittedPaths.Contains(itemPath))
+ continue;
+
+ EmitNewArrayItem(sb, item, itemPath, path, needsComma: idx > 0 || prevCount > 0);
+ }
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+ // VALUE EMISSION (for subsequent chunks)
+ // ═══════════════════════════════════════════════════════════════════════════════════════════
+
+ ///
+ /// Emits a string property with the given name, value, and path.
+ /// Optionally closes any open string first and keeps the new string open.
+ ///
+ private void EmitString(StringBuilder sb, string name, string value, string path, string parentPath, bool closeExisting, bool keepOpen)
+ {
+ if (closeExisting)
+ CloseOpenString(sb);
+
+ CloseStructuresDownTo(sb, parentPath);
+
+ if (HasEmittedSiblingAt(parentPath))
+ sb.Append(',');
+
+ sb.Append("\"" + Escape(name) + "\":\"" + Escape(value));
+
+ if (keepOpen)
+ _openStringPath = path;
+ else
+ sb.Append('"');
+
+ _emittedStrings[path] = value;
+ _emittedPaths.Add(path);
+ }
+
+ ///
+ /// Emits a pending string that has been resolved.
+ ///
+ private void EmitPendingString(StringBuilder sb, string path, string value, bool keepOpen)
+ {
+ var (parentPath, propName) = SplitPath(path);
+ EmitString(sb, propName, value, path, parentPath, closeExisting: true, keepOpen);
+ }
+
+ ///
+ /// Emits a new string property (just discovered in current chunk).
+ ///
+ private void EmitNewStringProperty(StringBuilder sb, string name, string value, string path, string parentPath, bool keepOpen) =>
+ EmitString(sb, name, value, path, parentPath, closeExisting: false, keepOpen);
+
+ ///
+ /// Emits a new non-string property (numbers, bools, null, objects, arrays).
+ ///
+ private void EmitNewProperty(StringBuilder sb, string name, JsonElement value, string path, string parentPath)
+ {
+ CloseOpenString(sb);
+ CloseStructuresDownTo(sb, parentPath);
+
+ if (HasEmittedSiblingAt(parentPath))
+ sb.Append(',');
+
+ sb.Append("\"" + Escape(name) + "\":");
+ EmitValue(sb, value, path);
+ _emittedPaths.Add(path);
+ }
+
+ ///
+ /// Emits a new array item.
+ ///
+ private void EmitNewArrayItem(StringBuilder sb, JsonElement value, string path, string arrayPath, bool needsComma)
+ {
+ CloseOpenString(sb);
+ CloseStructuresDownTo(sb, arrayPath);
+
+ if (needsComma)
+ sb.Append(',');
+
+ if (value.ValueKind == JsonValueKind.Object)
+ {
+ sb.Append('{');
+ _openStructures.Push((path, false));
+ _emittedPaths.Add(path);
+ EmitObjectContent(sb, value, path);
+ }
+ else if (value.ValueKind == JsonValueKind.Array)
+ {
+ sb.Append('[');
+ _openStructures.Push((path, true));
+ _emittedPaths.Add(path);
+ EmitArrayContent(sb, value, path);
+ }
+ else
+ {
+ EmitValue(sb, value, path);
+ _emittedPaths.Add(path);
+ }
+ }
+
+ #region Structure Emission (First Chunk)
+
+ ///
+ /// Recursively emits structure for first chunk processing.
+ ///
+ private void EmitStructure(StringBuilder sb, JsonElement elem, string path,
+ Dictionary state, Dictionary> stringsByParent)
+ {
+ if (elem.ValueKind == JsonValueKind.Object)
+ EmitObjectStructure(sb, elem, path, state, stringsByParent);
+ else if (elem.ValueKind == JsonValueKind.Array)
+ EmitArrayStructure(sb, elem, path, state, stringsByParent);
+ }
+
+ ///
+ /// Emits object structure for first chunk. Handles string ambiguity (single vs multiple growables).
+ ///
+ private void EmitObjectStructure(StringBuilder sb, JsonElement elem, string path,
+ Dictionary state, Dictionary> stringsByParent)
+ {
+ var stringsAtLevel = stringsByParent.GetValueOrDefault(path, new List());
+ int growablesAtLevel = CountGrowablesAtLevel(elem, path);
+ bool isFirst = true;
+
+ foreach (var prop in elem.EnumerateObject())
+ {
+ var propPath = CombinePath(path, prop.Name);
+
+ if (prop.Value.ValueKind == JsonValueKind.String)
+ {
+ // If only 1 growable at this level AND it's this string, emit it open
+ // Otherwise, add to pending (we can't tell which growable will grow)
+ if (growablesAtLevel == 1 && stringsAtLevel.Count == 1)
+ {
+ // Single growable and it's a string - emit it open (potentially partial)
+ if (!isFirst)
+ sb.Append(',');
+ isFirst = false;
+ sb.Append("\"" + Escape(prop.Name) + "\":\"" + Escape(prop.Value.GetString() ?? ""));
+ _openStringPath = propPath;
+ _emittedStrings[propPath] = prop.Value.GetString() ?? "";
+ _emittedPaths.Add(propPath);
+ }
+ else
+ {
+ // Multiple growables at this level - add string to pending
+ _pendingStrings[propPath] = prop.Value.GetString() ?? "";
+ }
+ }
+ else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
+ {
+ // Container property (array or object) - may be growable
+ bool isArray = prop.Value.ValueKind == JsonValueKind.Array;
+
+ if (growablesAtLevel == 1)
+ {
+ // Single growable - emit structure
+ if (!isFirst)
+ sb.Append(',');
+ isFirst = false;
+ sb.Append("\"" + Escape(prop.Name) + "\":");
+ _emittedPaths.Add(propPath);
+
+ sb.Append(isArray ? '[' : '{');
+ _openStructures.Push((propPath, isArray));
+ EmitStructure(sb, prop.Value, propPath, state, stringsByParent);
+ }
+ else
+ {
+ // Multiple growables at this level - add container to pending
+ _pendingContainers[propPath] = isArray;
+ }
+ }
+ else
+ {
+ // Non-growable property (number, bool, null) - emit immediately
+ if (!isFirst)
+ sb.Append(',');
+ isFirst = false;
+ sb.Append("\"" + Escape(prop.Name) + "\":");
+ _emittedPaths.Add(propPath);
+ EmitValue(sb, prop.Value, propPath);
+ }
+ }
+ }
+
+ ///
+ /// Emits array structure for first chunk.
+ ///
+ private void EmitArrayStructure(StringBuilder sb, JsonElement elem, string path,
+ Dictionary state, Dictionary> stringsByParent)
+ {
+ int idx = 0;
+ foreach (var item in elem.EnumerateArray())
+ {
+ var itemPath = path + "[" + idx + "]";
+
+ if (idx > 0)
+ {
+ // Emit any pending items from the previous array item (they are complete)
+ var prevItemPath = path + "[" + (idx - 1) + "]";
+ EmitPendingItemsUnder(sb, prevItemPath);
+
+ // Close any open string from previous array item before moving to next
+ CloseOpenString(sb);
+ // Close any open structures down to the array level
+ CloseStructuresDownTo(sb, path);
+ sb.Append(',');
+ }
+ _emittedPaths.Add(itemPath);
+
+ if (item.ValueKind == JsonValueKind.Object)
+ {
+ sb.Append('{');
+ _openStructures.Push((itemPath, false));
+ EmitStructure(sb, item, itemPath, state, stringsByParent);
+ }
+ else if (item.ValueKind == JsonValueKind.Array)
+ {
+ sb.Append('[');
+ _openStructures.Push((itemPath, true));
+ EmitStructure(sb, item, itemPath, state, stringsByParent);
+ }
+ else if (item.ValueKind == JsonValueKind.String)
+ {
+ // String in array - emit open (potentially partial)
+ sb.Append("\"" + Escape(item.GetString() ?? ""));
+ _openStringPath = itemPath;
+ _emittedStrings[itemPath] = item.GetString() ?? "";
+ }
+ else
+ {
+ EmitValue(sb, item, itemPath);
+ }
+
+ idx++;
+ }
+ }
+
+ ///
+ /// Emits object content (used for new array items that are objects).
+ ///
+ private void EmitObjectContent(StringBuilder sb, JsonElement elem, string path)
+ {
+ var stringProps = new List<(string Name, string Value, string Path)>();
+ var containerProps = new List<(string Name, JsonElement Value, string Path, bool IsArray)>();
+ int growablesAtLevel = CountGrowablesAtLevel(elem, path);
+ bool isFirst = true;
+
+ foreach (var prop in elem.EnumerateObject())
+ {
+ var propPath = CombinePath(path, prop.Name);
+
+ if (prop.Value.ValueKind == JsonValueKind.String)
+ {
+ // Collect strings to handle later
+ stringProps.Add((prop.Name, prop.Value.GetString() ?? "", propPath));
+ }
+ else if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array)
+ {
+ // Collect containers to handle later
+ containerProps.Add((prop.Name, prop.Value, propPath, prop.Value.ValueKind == JsonValueKind.Array));
+ }
+ else
+ {
+ // Non-growable - emit immediately
+ if (!isFirst)
+ sb.Append(',');
+ isFirst = false;
+ sb.Append("\"" + Escape(prop.Name) + "\":");
+ _emittedPaths.Add(propPath);
+ EmitValue(sb, prop.Value, propPath);
+ }
+ }
+
+ // Handle growable properties
+ if (growablesAtLevel == 1)
+ {
+ // Single growable at this level
+ if (stringProps.Count == 1)
+ {
+ var (name, value, propPath) = stringProps[0];
+ if (!isFirst)
+ sb.Append(',');
+ sb.Append("\"" + Escape(name) + "\":\"" + Escape(value));
+ _openStringPath = propPath;
+ _emittedStrings[propPath] = value;
+ _emittedPaths.Add(propPath);
+ }
+ else if (containerProps.Count == 1)
+ {
+ var (name, value, propPath, isArray) = containerProps[0];
+ if (!isFirst)
+ sb.Append(',');
+ sb.Append("\"" + Escape(name) + "\":");
+ _emittedPaths.Add(propPath);
+
+ sb.Append(isArray ? '[' : '{');
+ _openStructures.Push((propPath, isArray));
+ if (isArray)
+ EmitArrayContent(sb, value, propPath);
+ else
+ EmitObjectContent(sb, value, propPath);
+ }
+ }
+ else if (growablesAtLevel > 1)
+ {
+ // Multiple growables - add all to pending
+ foreach (var (_, value, propPath) in stringProps)
+ _pendingStrings[propPath] = value;
+ foreach (var (_, _, propPath, isArray) in containerProps)
+ _pendingContainers[propPath] = isArray;
+ }
+ }
+
+ ///
+ /// Emits array content (used for new array items that are arrays).
+ ///
+ private void EmitArrayContent(StringBuilder sb, JsonElement elem, string path)
+ {
+ int idx = 0;
+ foreach (var item in elem.EnumerateArray())
+ {
+ if (idx > 0)
+ sb.Append(',');
+
+ var itemPath = path + "[" + idx + "]";
+ _emittedPaths.Add(itemPath);
+
+ if (item.ValueKind == JsonValueKind.Object)
+ {
+ sb.Append('{');
+ _openStructures.Push((itemPath, false));
+ EmitObjectContent(sb, item, itemPath);
+ }
+ else if (item.ValueKind == JsonValueKind.Array)
+ {
+ sb.Append('[');
+ _openStructures.Push((itemPath, true));
+ EmitArrayContent(sb, item, itemPath);
+ }
+ else if (item.ValueKind == JsonValueKind.String)
+ {
+ sb.Append("\"" + Escape(item.GetString() ?? ""));
+ _openStringPath = itemPath;
+ _emittedStrings[itemPath] = item.GetString() ?? "";
+ }
+ else
+ {
+ EmitValue(sb, item, itemPath);
+ }
+
+ idx++;
+ }
+ }
+
+ #endregion
+
+ #region Value Emission
+
+ ///
+ /// Emits a JSON value (handles all types).
+ ///
+ private void EmitValue(StringBuilder sb, JsonElement elem, string path)
+ {
+ switch (elem.ValueKind)
+ {
+ case JsonValueKind.Object:
+ sb.Append('{');
+ _openStructures.Push((path, false));
+ EmitObjectContent(sb, elem, path);
+ break;
+ case JsonValueKind.Array:
+ sb.Append('[');
+ _openStructures.Push((path, true));
+ EmitArrayContent(sb, elem, path);
+ break;
+ case JsonValueKind.String:
+ sb.Append('"');
+ sb.Append(Escape(elem.GetString() ?? ""));
+ sb.Append('"');
+ _emittedStrings[path] = elem.GetString() ?? "";
+ break;
+ case JsonValueKind.Number:
+ sb.Append(elem.GetRawText());
+ break;
+ case JsonValueKind.True:
+ sb.Append("true");
+ break;
+ case JsonValueKind.False:
+ sb.Append("false");
+ break;
+ case JsonValueKind.Null:
+ sb.Append("null");
+ break;
+ }
+ }
+
+ #endregion
+
+ #region Structure Management
+
+ ///
+ /// Closes open structures (objects/arrays) down to the target path.
+ /// Used when we need to emit content at a different level in the JSON tree.
+ ///
+ private void CloseStructuresDownTo(StringBuilder sb, string targetPath)
+ {
+ while (_openStructures.Count > 0)
+ {
+ var (topPath, isArray) = _openStructures.Peek();
+
+ // Check if targetPath is at or inside topPath
+ bool isPrefix = targetPath.StartsWith(topPath) &&
+ (targetPath.Length == topPath.Length ||
+ targetPath.Length > topPath.Length && (targetPath[topPath.Length] == '.' || targetPath[topPath.Length] == '['));
+
+ if (topPath == "" || isPrefix)
+ break;
+
+ _openStructures.Pop();
+ sb.Append(isArray ? ']' : '}');
+ }
+ }
+
+ #endregion
+
+ #region State Comparison Helpers
+
+ ///
+ /// Checks if a new sibling property appeared at the given parent path.
+ ///
+ private bool HasNewSiblingAt(string parentPath, Dictionary prevState, Dictionary currState)
+ {
+ var prevProps = GetPropertiesAtPath(prevState, parentPath);
+ var currProps = GetPropertiesAtPath(currState, parentPath);
+
+ foreach (var prop in currProps)
+ {
+ if (!prevProps.Contains(prop))
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if a parent-level change occurred (e.g., new array item was added).
+ /// This signals that the current string is complete.
+ ///
+ private bool HasParentLevelChange(string path, Dictionary prevState, Dictionary currState)
+ {
+ var parentPath = GetParentPath(path);
+ if (string.IsNullOrEmpty(parentPath))
+ return false;
+
+ var grandparentPath = GetParentPath(parentPath);
+
+ // Check if parent is an array item and more items were added
+ if (parentPath.EndsWith("]"))
+ {
+ int prevCount = GetArrayCountAtPath(prevState, grandparentPath);
+ int currCount = GetArrayCountAtPath(currState, grandparentPath);
+ return currCount > prevCount;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks if we've already emitted a sibling at the given parent path (for comma management).
+ ///
+ private bool HasEmittedSiblingAt(string parentPath)
+ {
+ var prefix = string.IsNullOrEmpty(parentPath) ? "" : parentPath + ".";
+
+ foreach (var emitted in _emittedPaths)
+ {
+ if (emitted == parentPath)
+ continue;
+
+ if (string.IsNullOrEmpty(parentPath))
+ {
+ // Root level - check for direct children
+ if (!emitted.StartsWith("[", StringComparison.Ordinal) && emitted.IndexOf(".", StringComparison.Ordinal) < 0)
+ return true;
+ }
+ else if (emitted.StartsWith(prefix))
+ {
+ var remaining = emitted.Substring(prefix.Length);
+ // Direct child (no further nesting)
+ if (remaining.IndexOf(".", StringComparison.Ordinal) < 0 && remaining.IndexOf("[", StringComparison.Ordinal) < 0)
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ #endregion
+
+ #region Path Utilities
+
+ ///
+ /// Groups string paths by their parent path.
+ /// Used to determine if there are multiple strings at the same level.
+ ///
+ private static Dictionary> GroupStringsByParent(Dictionary state)
+ {
+ var result = new Dictionary>();
+
+ foreach (var (path, val) in state)
+ {
+ if (val.Kind == JsonValueKind.String)
+ {
+ var parent = GetParentPath(path);
+ if (!result.TryGetValue(parent, out var list))
+ {
+ list = new List();
+ result[parent] = list;
+ }
+ list.Add(path);
+ }
+ }
+
+ return result;
+ }
+
+ ///
+ /// Counts growable items (strings, arrays, objects) at a given parent path.
+ /// Used to determine if we have multiple ambiguous growable types.
+ ///
+ private static int CountGrowablesAtLevel(JsonElement elem, string path)
+ {
+ if (elem.ValueKind != JsonValueKind.Object)
+ return 0;
+
+ int count = 0;
+ foreach (var prop in elem.EnumerateObject())
+ {
+ if (IsGrowable(prop.Value.ValueKind))
+ count++;
+ }
+ return count;
+ }
+
+ ///
+ /// Gets the parent path from a full path.
+ /// E.g., "days[0].title" → "days[0]", "days[0]" → "days"
+ ///
+ private static string GetParentPath(string path)
+ {
+ var lastDot = path.LastIndexOf('.');
+ var lastBracket = path.LastIndexOf('[');
+
+ if (lastDot > lastBracket && lastDot >= 0)
+ return path.Substring(0, lastDot);
+ if (lastBracket >= 0)
+ return path.Substring(0, lastBracket);
+
+ return "";
+ }
+
+ ///
+ /// Splits a path into parent and property name.
+ /// E.g., "days[0].title" → ("days[0]", "title")
+ ///
+ private static (string Parent, string Name) SplitPath(string path)
+ {
+ var lastDot = path.LastIndexOf('.');
+ var lastBracket = path.LastIndexOf('[');
+
+ if (lastDot > lastBracket && lastDot >= 0)
+ return (path.Substring(0, lastDot), path.Substring(lastDot + 1));
+ if (lastBracket >= 0)
+ return (path.Substring(0, lastBracket), path);
+ return ("", path);
+ }
+
+ ///
+ /// Combines parent path with child name.
+ /// E.g., ("days[0]", "title") → "days[0].title"
+ ///
+ private static string CombinePath(string parent, string child) =>
+ string.IsNullOrEmpty(parent) ? child : parent + "." + child;
+
+ ///
+ /// Gets all direct property names at a given path.
+ ///
+ private static HashSet GetPropertiesAtPath(Dictionary state, string path)
+ {
+ var props = new HashSet();
+ var prefix = string.IsNullOrEmpty(path) ? "" : path + ".";
+
+ foreach (var key in state.Keys)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ // Root level properties
+ if (!key.StartsWith("["))
+ {
+ var dotIdx = key.IndexOf('.', StringComparison.Ordinal);
+ var bracketIdx = key.IndexOf('[', StringComparison.Ordinal);
+ var endIdx = dotIdx >= 0 && bracketIdx >= 0 ? Math.Min(dotIdx, bracketIdx) :
+ dotIdx >= 0 ? dotIdx : bracketIdx >= 0 ? bracketIdx : key.Length;
+ props.Add(key.Substring(0, endIdx));
+ }
+ }
+ else if (key.StartsWith(prefix))
+ {
+ // Child properties
+ var remaining = key.Substring(prefix.Length);
+ var dotIdx = remaining.IndexOf('.', StringComparison.Ordinal);
+ var bracketIdx = remaining.IndexOf('[', StringComparison.Ordinal);
+ var endIdx = dotIdx >= 0 && bracketIdx >= 0 ? Math.Min(dotIdx, bracketIdx) :
+ dotIdx >= 0 ? dotIdx : bracketIdx >= 0 ? bracketIdx : remaining.Length;
+
+ if (endIdx > 0)
+ props.Add(remaining.Substring(0, endIdx));
+ }
+ }
+
+ return props;
+ }
+
+ ///
+ /// Gets the count of array items at a given array path.
+ ///
+ private static int GetArrayCountAtPath(Dictionary state, string path)
+ {
+ int maxIdx = -1;
+ var prefix = path + "[";
+
+ foreach (var key in state.Keys)
+ {
+ if (key.StartsWith(prefix))
+ {
+ var bracketEnd = key.IndexOf(']', prefix.Length);
+ if (bracketEnd > prefix.Length)
+ {
+ var idxStr = key.Substring(prefix.Length, bracketEnd - prefix.Length);
+ if (int.TryParse(idxStr, out var idx))
+ maxIdx = Math.Max(maxIdx, idx);
+ }
+ }
+ }
+
+ return maxIdx + 1;
+ }
+
+ #endregion
+
+ #region JSON Flattening
+
+ ///
+ /// Flattens a JSON element into a path→value dictionary.
+ /// E.g., {"name": "John", "address": {"city": "NYC"}} becomes:
+ /// "name" → "John"
+ /// "address.city" → "NYC"
+ ///
+ private static Dictionary FlattenJson(JsonElement elem, string path)
+ {
+ var result = new Dictionary();
+ FlattenJsonRecursive(elem, path, result);
+ return result;
+ }
+
+ ///
+ /// Recursively flattens JSON into path→value entries.
+ ///
+ private static void FlattenJsonRecursive(JsonElement elem, string path, Dictionary result)
+ {
+ switch (elem.ValueKind)
+ {
+ case JsonValueKind.Object:
+ if (!elem.EnumerateObject().Any())
+ {
+ // Empty object - store it so we know it exists
+ result[path] = new JsonValue(JsonValueKind.Object, null, null);
+ }
+ else
+ {
+ foreach (var prop in elem.EnumerateObject())
+ {
+ var propPath = string.IsNullOrEmpty(path) ? prop.Name : path + "." + prop.Name;
+ FlattenJsonRecursive(prop.Value, propPath, result);
+ }
+ }
+ break;
+
+ case JsonValueKind.Array:
+ if (elem.GetArrayLength() == 0)
+ {
+ // Empty array - store it so we know it exists
+ result[path] = new JsonValue(JsonValueKind.Array, null, null);
+ }
+ else
+ {
+ int i = 0;
+ foreach (var item in elem.EnumerateArray())
+ {
+ FlattenJsonRecursive(item, path + "[" + i + "]", result);
+ i++;
+ }
+ }
+ break;
+
+ case JsonValueKind.String:
+ result[path] = new JsonValue(JsonValueKind.String, elem.GetString(), null);
+ break;
+
+ case JsonValueKind.Number:
+ result[path] = new JsonValue(JsonValueKind.Number, null, elem.GetRawText());
+ break;
+
+ case JsonValueKind.True:
+ result[path] = new JsonValue(JsonValueKind.True, null, null);
+ break;
+
+ case JsonValueKind.False:
+ result[path] = new JsonValue(JsonValueKind.False, null, null);
+ break;
+
+ case JsonValueKind.Null:
+ result[path] = new JsonValue(JsonValueKind.Null, null, null);
+ break;
+ }
+ }
+
+ #endregion
+
+ #region String Escaping
+
+ ///
+ /// Escapes a string for JSON output.
+ ///
+ private static string Escape(string s)
+ {
+ var sb = new StringBuilder();
+
+ foreach (var c in s)
+ {
+ switch (c)
+ {
+ case '"':
+ sb.Append("\\\"");
+ break;
+ case '\\':
+ sb.Append("\\\\");
+ break;
+ case '\n':
+ sb.Append("\\n");
+ break;
+ case '\r':
+ sb.Append("\\r");
+ break;
+ case '\t':
+ sb.Append("\\t");
+ break;
+ default:
+ sb.Append(c);
+ break;
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ #endregion
+}
diff --git a/src/AI/src/Essentials.AI/Platform/MaciOS/ApiDefinitions.cs b/src/AI/src/Essentials.AI/Platform/MaciOS/ApiDefinitions.cs
new file mode 100644
index 000000000000..9fee014d3697
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/MaciOS/ApiDefinitions.cs
@@ -0,0 +1,286 @@
+#nullable enable
+
+using System;
+using System.Threading.Tasks;
+using Foundation;
+using ObjCRuntime;
+
+namespace Microsoft.Maui.Essentials.AI;
+
+// typedef void (^AppleIntelligenceLogAction)(NSString * _Nonnull);
+[Internal]
+delegate void AppleIntelligenceLogAction(string message);
+
+// @interface AppleIntelligenceLogger : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+[BaseType(typeof(NSObject))]
+[Internal]
+interface AppleIntelligenceLogger
+{
+ // @property (class, nonatomic, copy) void (^ _Nullable)(NSString * _Nonnull) log;
+ [Static]
+ [NullAllowed, Export("log", ArgumentSemantic.Copy)]
+ AppleIntelligenceLogAction Log { get; set; }
+}
+
+// @interface AIContentNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[Internal]
+interface AIContentNative
+{
+}
+
+// This is essential to keep as we need to reference IAIToolNative in this file
+interface IAIToolNative { }
+
+// @protocol AIToolNative
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[Protocol, Model]
+[BaseType(typeof(NSObject))]
+[Internal]
+interface AIToolNative
+{
+ // @property (nonatomic, readonly, copy) NSString * _Nonnull name;
+ [Abstract]
+ [Export("name")]
+ string Name { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nonnull desc;
+ [Abstract]
+ [Export("desc")]
+ string Desc { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nonnull argumentsSchema;
+ [Abstract]
+ [Export("argumentsSchema")]
+ string ArgumentsSchema { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nonnull outputSchema;
+ [Abstract]
+ [Export("outputSchema")]
+ string OutputSchema { get; }
+
+ // - (void)callWithArguments:(NSString * _Nonnull)arguments completion:(void (^ _Nonnull)(NSString * _Nonnull))completion;
+ [Abstract]
+ [Export("callWithArguments:completion:")]
+ void CallWithArguments(NSString arguments, Action completion);
+}
+
+// @interface CancellationTokenNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[DisableDefaultCtor]
+[Internal]
+interface CancellationTokenNative
+{
+ // - (void)cancel;
+ [Export("cancel")]
+ void Cancel();
+
+ // @property (nonatomic, readonly) BOOL isCancelled;
+ [Export("isCancelled")]
+ bool IsCancelled { get; }
+}
+
+[Internal] delegate void OnResponseUpdate(ResponseUpdateNative update);
+
+[Internal] delegate void OnResponseComplete([NullAllowed] ChatResponseNative response, [NullAllowed] NSError error);
+
+// @interface ChatClientNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[Internal]
+interface ChatClientNative
+{
+ // - (CancellationTokenNative * _Nullable)streamResponseWithMessages:(NSArray * _Nonnull)messages options:(ChatOptionsNative * _Nullable)options onUpdate:(void (^ _Nonnull)(ResponseUpdateNative * _Nonnull))onUpdate onComplete:(void (^ _Nonnull)(ChatResponseNative * _Nullable, NSError * _Nullable))onComplete SWIFT_WARN_UNUSED_RESULT;
+ [Export("streamResponseWithMessages:options:onUpdate:onComplete:")]
+ [return: NullAllowed]
+ unsafe CancellationTokenNative StreamResponse(ChatMessageNative[] messages, [NullAllowed] ChatOptionsNative options, OnResponseUpdate onUpdate, OnResponseComplete onComplete);
+
+ // - (CancellationTokenNative * _Nullable)getResponseWithMessages:(NSArray * _Nonnull)messages options:(ChatOptionsNative * _Nullable)options onUpdate:(void (^ _Nonnull)(ResponseUpdateNative * _Nonnull))onUpdate onComplete:(void (^ _Nonnull)(ChatResponseNative * _Nullable, NSError * _Nullable))onComplete SWIFT_WARN_UNUSED_RESULT;
+ [Export("getResponseWithMessages:options:onUpdate:onComplete:")]
+ [return: NullAllowed]
+ unsafe CancellationTokenNative GetResponse(ChatMessageNative[] messages, [NullAllowed] ChatOptionsNative options, OnResponseUpdate onUpdate, OnResponseComplete onComplete);
+}
+
+// @interface ChatMessageNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[Internal]
+interface ChatMessageNative
+{
+ // @property (nonatomic) enum ChatRoleNative role;
+ [Export("role", ArgumentSemantic.Assign)]
+ ChatRoleNative Role { get; set; }
+
+ // @property (nonatomic, copy) NSArray * _Nonnull contents;
+ [Export("contents", ArgumentSemantic.Copy)]
+ AIContentNative[] Contents { get; set; }
+}
+
+// @interface ChatOptionsNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[Internal]
+interface ChatOptionsNative
+{
+ // @property (nonatomic, strong) NSNumber * _Nullable topK;
+ [NullAllowed, Export("topK", ArgumentSemantic.Strong)]
+ NSNumber TopK { get; set; }
+
+ // @property (nonatomic, strong) NSNumber * _Nullable seed;
+ [NullAllowed, Export("seed", ArgumentSemantic.Strong)]
+ NSNumber Seed { get; set; }
+
+ // @property (nonatomic, strong) NSNumber * _Nullable temperature;
+ [NullAllowed, Export("temperature", ArgumentSemantic.Strong)]
+ NSNumber Temperature { get; set; }
+
+ // @property (nonatomic, strong) NSNumber * _Nullable maxOutputTokens;
+ [NullAllowed, Export("maxOutputTokens", ArgumentSemantic.Strong)]
+ NSNumber MaxOutputTokens { get; set; }
+
+ // @property (nonatomic, strong) NSString * _Nullable responseJsonSchema;
+ [NullAllowed, Export("responseJsonSchema", ArgumentSemantic.Strong)]
+ NSString ResponseJsonSchema { get; set; }
+
+ // @property (nonatomic, copy) NSArray> * _Nullable tools;
+ [NullAllowed, Export("tools", ArgumentSemantic.Copy)]
+ IAIToolNative[] Tools { get; set; }
+}
+
+// @interface ChatResponseNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[DisableDefaultCtor]
+[Internal]
+interface ChatResponseNative
+{
+ // @property (nonatomic, copy) NSArray * _Nonnull messages;
+ [Export("messages", ArgumentSemantic.Copy)]
+ ChatMessageNative[] Messages { get; set; }
+
+ // - (nonnull instancetype)initWithMessages:(NSArray * _Nonnull)messages OBJC_DESIGNATED_INITIALIZER;
+ [Export("initWithMessages:")]
+ [DesignatedInitializer]
+ NativeHandle Constructor(ChatMessageNative[] messages);
+}
+
+// @interface FunctionCallContentNative : AIContentNative
+[BaseType(typeof(AIContentNative))]
+[DisableDefaultCtor]
+[Internal]
+interface FunctionCallContentNative
+{
+ // @property (nonatomic, copy) NSString * _Nonnull callId;
+ [Export("callId", ArgumentSemantic.Copy)]
+ string CallId { get; set; }
+
+ // @property (nonatomic, copy) NSString * _Nonnull name;
+ [Export("name", ArgumentSemantic.Copy)]
+ string Name { get; set; }
+
+ // @property (nonatomic, copy) NSString * _Nonnull arguments;
+ [Export("arguments", ArgumentSemantic.Copy)]
+ string Arguments { get; set; }
+
+ // - (nonnull instancetype)initWithCallId:(NSString * _Nonnull)callId name:(NSString * _Nonnull)name arguments:(NSString * _Nonnull)arguments OBJC_DESIGNATED_INITIALIZER;
+ [Export("initWithCallId:name:arguments:")]
+ [DesignatedInitializer]
+ NativeHandle Constructor(string callId, string name, string arguments);
+}
+
+// @interface FunctionResultContentNative : AIContentNative
+[BaseType(typeof(AIContentNative))]
+[DisableDefaultCtor]
+[Internal]
+interface FunctionResultContentNative
+{
+ // @property (nonatomic, copy) NSString * _Nonnull callId;
+ [Export("callId", ArgumentSemantic.Copy)]
+ string CallId { get; set; }
+
+ // @property (nonatomic, copy) NSString * _Nonnull result;
+ [Export("result", ArgumentSemantic.Copy)]
+ string Result { get; set; }
+
+ // - (nonnull instancetype)initWithCallId:(NSString * _Nonnull)callId result:(NSString * _Nonnull)result OBJC_DESIGNATED_INITIALIZER;
+ [Export("initWithCallId:result:")]
+ [DesignatedInitializer]
+ NativeHandle Constructor(string callId, string result);
+}
+
+// @interface TextContentNative : AIContentNative
+[BaseType(typeof(AIContentNative))]
+[DisableDefaultCtor]
+[Internal]
+interface TextContentNative
+{
+ // - (nonnull instancetype)initWithText:(NSString * _Nonnull)text OBJC_DESIGNATED_INITIALIZER;
+ [Export("initWithText:")]
+ [DesignatedInitializer]
+ NativeHandle Constructor(string text);
+
+ // @property (nonatomic, copy) NSString * _Nonnull text;
+ [Export("text")]
+ string Text { get; set; }
+}
+
+// @interface ResponseUpdateNative : NSObject
+[Introduced(PlatformName.iOS, 26, 0)]
+[Introduced(PlatformName.MacCatalyst, 26, 0)]
+[Introduced(PlatformName.MacOSX, 26, 0)]
+// [Introduced (PlatformName.VisionOS, 26, 0)]
+[BaseType(typeof(NSObject))]
+[DisableDefaultCtor]
+[Internal]
+interface ResponseUpdateNative
+{
+ // @property (nonatomic, readonly) enum ResponseUpdateTypeNative updateType;
+ [Export("updateType")]
+ ResponseUpdateTypeNative UpdateType { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nullable text;
+ [NullAllowed, Export("text")]
+ string Text { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nullable toolCallId;
+ [NullAllowed, Export("toolCallId")]
+ string ToolCallId { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nullable toolCallName;
+ [NullAllowed, Export("toolCallName")]
+ string ToolCallName { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nullable toolCallArguments;
+ [NullAllowed, Export("toolCallArguments")]
+ string ToolCallArguments { get; }
+
+ // @property (nonatomic, readonly, copy) NSString * _Nullable toolCallResult;
+ [NullAllowed, Export("toolCallResult")]
+ string ToolCallResult { get; }
+}
diff --git a/src/AI/src/Essentials.AI/Platform/MaciOS/AppleIntelligenceChatClient.cs b/src/AI/src/Essentials.AI/Platform/MaciOS/AppleIntelligenceChatClient.cs
new file mode 100644
index 000000000000..b8748cd2f28e
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/MaciOS/AppleIntelligenceChatClient.cs
@@ -0,0 +1,463 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.Versioning;
+using System.Threading.Channels;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace Microsoft.Maui.Essentials.AI;
+
+///
+/// Provides an implementation based on native Apple Intelligence APIs
+///
+[SupportedOSPlatform("ios26.0")]
+[SupportedOSPlatform("maccatalyst26.0")]
+[SupportedOSPlatform("macos26.0")]
+public sealed partial class AppleIntelligenceChatClient : ChatClientBase
+{
+#if DEBUG
+ static AppleIntelligenceChatClient()
+ {
+ // Enable native logging for debugging purposes, this is quite verbose.
+ // AppleIntelligenceLogger.Log = (message) => System.Diagnostics.Debug.WriteLine("[Native] " + message);
+ }
+#endif
+
+
+ ///
+ /// Initializes a new instance.
+ ///
+ public AppleIntelligenceChatClient()
+ : this(null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance with the specified logger.
+ ///
+ /// An optional instance for logging chat operations.
+ public AppleIntelligenceChatClient(ILogger? logger)
+ : base(logger)
+ {
+ }
+
+ ///
+ /// Initializes a new instance with the specified logger.
+ ///
+ /// An optional instance for logging chat operations.
+ public AppleIntelligenceChatClient(ILogger? logger)
+ : base(logger)
+ {
+ }
+
+ ///
+ internal override string ProviderName => "apple";
+
+ ///
+ internal override string DefaultModelId => "apple-intelligence";
+
+ internal static AIJsonSchemaTransformCache StrictSchemaTransformCache { get; } =
+ new(new()
+ {
+ DisallowAdditionalProperties = true,
+ ConvertBooleanSchemas = true,
+ MoveDefaultKeywordToDescription = true,
+ RequireAllProperties = true,
+ TransformSchemaNode = (ctx, node) =>
+ {
+ // Handle objects
+ if (node is JsonObject obj && obj.TryGetPropertyValue("type", out var typeNode) && typeNode?.GetValue() == "object")
+ {
+ // All objects need a title
+ if (!obj.ContainsKey("title"))
+ {
+ obj["title"] = Guid.NewGuid().ToString("N");
+ }
+ }
+
+ return node;
+ },
+ });
+
+ ///
+ public override Task GetResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ LogMethodInvoked(nameof(GetResponseAsync), messages, options);
+
+ var nativeMessages = messages.Select(ToNative).ToArray();
+ var nativeOptions = options is null ? null : ToNative(options);
+ var native = new ChatClientNative();
+
+ var tcs = new TaskCompletionSource();
+
+ var nativeToken = native.GetResponse(
+ nativeMessages,
+ nativeOptions,
+ onUpdate: (update) =>
+ {
+ switch (update.UpdateType)
+ {
+ case ResponseUpdateTypeNative.ToolCall:
+ LogFunctionInvoking(nameof(GetResponseAsync), update.ToolCallName!, update.ToolCallId!, update.ToolCallArguments);
+ break;
+
+ case ResponseUpdateTypeNative.ToolResult:
+ LogFunctionInvocationCompleted(nameof(GetResponseAsync), update.ToolCallId!, update.ToolCallResult!);
+ break;
+
+ case ResponseUpdateTypeNative.Content:
+ default:
+ // Content updates are not used in non-streaming mode
+ break;
+ }
+ },
+ onComplete: (response, error) =>
+ {
+ if (error is not null)
+ {
+ if (error.Domain == nameof(ChatClientNative) && error.Code == (int)ChatClientError.Cancelled)
+ {
+ LogMethodCanceled(nameof(GetResponseAsync));
+ tcs.TrySetCanceled();
+ }
+ else
+ {
+ var ex = new NSErrorException(error);
+ LogMethodFailed(nameof(GetResponseAsync), ex);
+ tcs.TrySetException(ex);
+ }
+
+ return;
+ }
+
+ var chatResponse = FromNativeChatResponse(response);
+ LogMethodCompleted(nameof(GetResponseAsync), chatResponse);
+ tcs.TrySetResult(chatResponse);
+ });
+
+ cancellationToken.Register(() => nativeToken?.Cancel());
+
+ return tcs.Task;
+ }
+
+ ///
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ LogMethodInvoked(nameof(GetStreamingResponseAsync), messages, options);
+
+ if (options?.ResponseFormat is ChatResponseFormatJson jsonFormat &&
+ StrictSchemaTransformCache.GetOrCreateTransformedSchema(jsonFormat) is { } jsonSchema)
+ {
+ options.ResponseFormat = ChatResponseFormat.ForJsonSchema(
+ jsonSchema, jsonFormat.SchemaName, jsonFormat.SchemaDescription);
+ }
+
+ var nativeMessages = messages.Select(ToNative).ToArray();
+ var nativeOptions = options is null ? null : ToNative(options);
+
+ var native = new ChatClientNative();
+
+ var channel = Channel.CreateUnbounded();
+
+ // Use appropriate stream chunker based on response format
+ StreamChunkerBase chunker = options?.ResponseFormat is ChatResponseFormatJson
+ ? new JsonStreamChunker()
+ : new PlainTextStreamChunker();
+
+ var nativeToken = native.StreamResponse(
+ nativeMessages,
+ nativeOptions,
+ onUpdate: (update) =>
+ {
+ switch (update.UpdateType)
+ {
+ case ResponseUpdateTypeNative.Content:
+ // Handle text updates
+ if (update.Text is not null)
+ {
+ // Use stream chunker to compute delta - handles both JSON and plain text
+ var delta = chunker.Process(update.Text);
+
+ if (!string.IsNullOrEmpty(delta))
+ {
+ var chatUpdate = new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = { new TextContent(delta) }
+ };
+
+ LogStreamingUpdate(nameof(GetStreamingResponseAsync), chatUpdate);
+ channel.Writer.TryWrite(chatUpdate);
+ }
+ }
+ break;
+
+ case ResponseUpdateTypeNative.ToolCall:
+ LogFunctionInvoking(nameof(GetStreamingResponseAsync), update.ToolCallName!, update.ToolCallId!, update.ToolCallArguments);
+
+ var args = update.ToolCallArguments is null
+ ? null
+#pragma warning disable IL3050, IL2026 // DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled
+ : JsonSerializer.Deserialize(update.ToolCallArguments, AIJsonUtilities.DefaultOptions);
+#pragma warning restore IL3050, IL2026
+
+ var toolCallUpdate = new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = { new FunctionCallContent(update.ToolCallId!, update.ToolCallName!, args) }
+ };
+ channel.Writer.TryWrite(toolCallUpdate);
+ break;
+
+ case ResponseUpdateTypeNative.ToolResult:
+ LogFunctionInvocationCompleted(nameof(GetStreamingResponseAsync), update.ToolCallId!, update.ToolCallResult!);
+
+ var toolResultUpdate = new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = { new FunctionResultContent(update.ToolCallId!, update.ToolCallResult!) }
+ };
+ channel.Writer.TryWrite(toolResultUpdate);
+ break;
+ }
+ },
+ onComplete: (finalResult, error) =>
+ {
+ if (error is not null)
+ {
+ Exception ex;
+ if (error.Domain == nameof(ChatClientNative) && error.Code == (int)ChatClientError.Cancelled)
+ {
+ LogMethodCanceled(nameof(GetStreamingResponseAsync));
+ ex = new OperationCanceledException();
+ }
+ else
+ {
+ ex = new NSErrorException(error);
+ LogMethodFailed(nameof(GetStreamingResponseAsync), ex);
+ }
+
+ channel.Writer.Complete(ex);
+ }
+ else
+ {
+ // Flush any remaining content from the chunker
+ var finalChunk = chunker.Flush();
+ if (!string.IsNullOrEmpty(finalChunk))
+ {
+ var finalUpdate = new ChatResponseUpdate
+ {
+ Role = ChatRole.Assistant,
+ Contents = { new TextContent(finalChunk) }
+ };
+
+ LogStreamingUpdate(nameof(GetStreamingResponseAsync), finalUpdate);
+ channel.Writer.TryWrite(finalUpdate);
+ }
+
+ var chatResponse = FromNativeChatResponse(finalResult);
+ LogMethodCompleted(nameof(GetStreamingResponseAsync), chatResponse);
+ channel.Writer.Complete();
+ }
+ });
+
+ cancellationToken.Register(() => nativeToken?.Cancel());
+
+ await foreach (var update in channel.Reader.ReadAllAsync(cancellationToken))
+ {
+ yield return update;
+ }
+ }
+
+ private static ChatResponse FromNativeChatResponse(ChatResponseNative? response)
+ {
+ if (response is null || response.Messages is null || response.Messages.Length == 0)
+ {
+ // Fallback: return empty response
+ return new ChatResponse([new ChatMessage(ChatRole.Assistant, "")]);
+ }
+
+ // Convert all native messages to ChatMessage objects
+ var messages = response.Messages
+ .Select(FromNative)
+ .ToList();
+
+ // Create ChatResponse with all messages
+ return new ChatResponse(messages);
+ }
+
+ private static ChatMessage FromNative(ChatMessageNative nativeMessage)
+ {
+ var message = new ChatMessage
+ {
+ Role = FromNative(nativeMessage.Role)
+ };
+
+ if (nativeMessage.Contents is not null)
+ {
+ foreach (var content in nativeMessage.Contents)
+ {
+ message.Contents.Add(FromNative(content));
+ }
+ }
+
+ return message;
+ }
+
+ private static ChatRole FromNative(ChatRoleNative role) =>
+ role switch
+ {
+ ChatRoleNative.User => ChatRole.User,
+ ChatRoleNative.Assistant => ChatRole.Assistant,
+ ChatRoleNative.System => ChatRole.System,
+ ChatRoleNative.Tool => ChatRole.Tool,
+ _ => throw new ArgumentOutOfRangeException(nameof(role), $"Unknown role: {role}")
+ };
+
+ private static AIContent FromNative(AIContentNative content) =>
+ content switch
+ {
+ TextContentNative textContent =>
+ new TextContent(textContent.Text),
+
+ FunctionCallContentNative functionCall =>
+#pragma warning disable IL3050, IL2026
+ new FunctionCallContent(
+ functionCall.CallId,
+ functionCall.Name,
+ JsonSerializer.Deserialize(
+ functionCall.Arguments,
+ AIJsonUtilities.DefaultOptions)),
+#pragma warning restore IL3050, IL2026
+
+ FunctionResultContentNative functionResult =>
+ new FunctionResultContent(
+ functionResult.CallId,
+ functionResult.Result),
+
+ _ => throw new ArgumentException($"Unsupported content type: {content.GetType().Name}", nameof(content))
+ };
+
+ private static ChatMessageNative ToNative(ChatMessage message) =>
+ new()
+ {
+ Role = ToNative(message.Role),
+ Contents = [.. message.Contents.SelectMany(ToNative)]
+ };
+
+ private static ChatRoleNative ToNative(ChatRole role)
+ {
+ if (role == ChatRole.User)
+ return ChatRoleNative.User;
+ else if (role == ChatRole.Assistant)
+ return ChatRoleNative.Assistant;
+ else if (role == ChatRole.System)
+ return ChatRoleNative.System;
+ else if (role == ChatRole.Tool)
+ return ChatRoleNative.Tool;
+ else
+ throw new ArgumentOutOfRangeException(nameof(role), $"The role '{role}' is not supported by Apple Intelligence chat APIs.");
+ }
+
+ private static ChatOptionsNative ToNative(ChatOptions options) =>
+ new ChatOptionsNative
+ {
+ TopK = ToNative(options.TopK),
+ Seed = ToNative(options.Seed),
+ Temperature = ToNative(options.Temperature),
+ MaxOutputTokens = ToNative(options.MaxOutputTokens),
+ ResponseJsonSchema = ToNative(options.ResponseFormat),
+ Tools = ToNative(options.Tools)
+ };
+
+ private static AIFunctionToolAdapter[]? ToNative(IList? tools)
+ {
+ AIFunctionToolAdapter[]? adapters = null;
+
+ if (tools is { Count: > 0 })
+ {
+ // Note: Only AIFunction tools are supported for Apple Intelligence
+ // Other AITool implementations should be converted to AIFunction using AIFunctionFactory
+
+ adapters = tools
+ .OfType()
+ .Select(function => new AIFunctionToolAdapter(function))
+ .ToArray();
+ }
+
+ return adapters;
+ }
+
+ private static NSString? ToNative(ChatResponseFormat? format) =>
+ format switch
+ {
+ ChatResponseFormatJson jsonFormat => (NSString?)jsonFormat.Schema.ToString(),
+ _ => null
+ };
+
+ private static IEnumerable ToNative(AIContent content) =>
+ content switch
+ {
+ // Apple Intelligence performs better when each text content chunk is separated
+ TextContent textContent when textContent.Text is not null => [new TextContentNative(textContent.Text)],
+ TextContent => Array.Empty(),
+
+ // Throw for unsupported content types
+ _ => throw new ArgumentException($"The content type '{content.GetType().FullName}' is not supported by Apple Intelligence chat APIs.", nameof(content))
+ };
+
+ private static NSNumber? ToNative(int? value) =>
+ value.HasValue ? NSNumber.FromInt32(value.Value) : null;
+
+ private static NSNumber? ToNative(double? value) =>
+ value.HasValue ? NSNumber.FromDouble(value.Value) : null;
+
+ private static NSNumber? ToNative(long? value) =>
+ value.HasValue ? NSNumber.FromInt64(value.Value) : null;
+
+ private sealed class AIFunctionToolAdapter(AIFunction function) : AIToolNative
+ {
+ public override string Name => function.Name;
+
+ public override string Desc => function.Description;
+
+ public override string ArgumentsSchema => function.JsonSchema.GetRawText();
+
+ public override string OutputSchema => function.ReturnJsonSchema?.GetRawText() ?? "{\"type\":\"string\"}";
+
+#pragma warning disable IL3050, IL2026 // DefaultJsonTypeInfoResolver is only used when reflection-based serialization is enabled
+ public override async void CallWithArguments(NSString arguments, Action completion)
+ {
+ try
+ {
+ var aiArgs = JsonSerializer.Deserialize(arguments, AIJsonUtilities.DefaultOptions);
+
+ var result = await function.InvokeAsync(aiArgs, cancellationToken: default);
+
+ var resultJson = result is not null
+ ? JsonSerializer.Serialize(result)
+ : "{}";
+
+ completion(new NSString(resultJson));
+ }
+ catch (Exception ex)
+ {
+ var errorJson = JsonSerializer.Serialize(new
+ {
+ error = ex.Message,
+ type = ex.GetType().Name
+ });
+
+ completion(new NSString(errorJson));
+ }
+ }
+#pragma warning restore IL3050, IL2026
+ }
+}
diff --git a/src/AI/src/Essentials.AI/Platform/MaciOS/StructsAndEnums.cs b/src/AI/src/Essentials.AI/Platform/MaciOS/StructsAndEnums.cs
new file mode 100644
index 000000000000..f5dc62edb5fd
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/MaciOS/StructsAndEnums.cs
@@ -0,0 +1,24 @@
+namespace Microsoft.Maui.Essentials.AI;
+
+internal enum ChatClientError : long
+{
+ EmptyMessages = 1,
+ InvalidRole = 2,
+ InvalidContent = 3,
+ Cancelled = 4
+}
+
+internal enum ChatRoleNative : long
+{
+ User = 1,
+ Assistant = 2,
+ System = 3,
+ Tool = 4,
+}
+
+internal enum ResponseUpdateTypeNative : long
+{
+ Content = 0,
+ ToolCall = 1,
+ ToolResult = 2
+}
diff --git a/src/AI/src/Essentials.AI/Platform/PlainTextStreamChunker.cs b/src/AI/src/Essentials.AI/Platform/PlainTextStreamChunker.cs
new file mode 100644
index 000000000000..ab742f5d692e
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/PlainTextStreamChunker.cs
@@ -0,0 +1,36 @@
+namespace Microsoft.Maui.Essentials.AI;
+
+///
+/// A stream chunker for plain text responses that computes simple substring deltas.
+///
+///
+/// For plain text, the AI model outputs progressively longer text at each step.
+/// We simply emit the new characters since the last response.
+///
+internal sealed class PlainTextStreamChunker : StreamChunkerBase
+{
+ /// Tracks the last complete response to compute deltas.
+ private string _lastResponse = "";
+
+ ///
+ public override string Process(string completeResponse)
+ {
+ if (string.IsNullOrEmpty(completeResponse))
+ return string.Empty;
+
+ // Simple substring delta - emit only new characters
+ var delta = completeResponse.Length > _lastResponse.Length
+ ? completeResponse.Substring(_lastResponse.Length)
+ : string.Empty;
+
+ _lastResponse = completeResponse;
+ return delta;
+ }
+
+ ///
+ public override string Flush()
+ {
+ // Plain text has no pending state to flush
+ return string.Empty;
+ }
+}
diff --git a/src/AI/src/Essentials.AI/Platform/StreamChunkerBase.cs b/src/AI/src/Essentials.AI/Platform/StreamChunkerBase.cs
new file mode 100644
index 000000000000..af222c5c11a1
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Platform/StreamChunkerBase.cs
@@ -0,0 +1,25 @@
+namespace Microsoft.Maui.Essentials.AI;
+
+///
+/// Base class for stream chunkers that convert complete responses back into streaming chunks.
+///
+///
+/// Used when an AI model outputs complete responses at each step, but we want to stream
+/// partial output to the user for better UX. Implementations compare successive snapshots
+/// and emit only the delta (new/changed content).
+///
+internal abstract class StreamChunkerBase
+{
+ ///
+ /// Processes a complete snapshot and returns a streaming chunk representing the delta.
+ ///
+ /// A complete response representing the current state.
+ /// A string chunk to emit. Concatenating all chunks yields the final response.
+ public abstract string Process(string completeResponse);
+
+ ///
+ /// Flushes any remaining state and closes all pending output.
+ ///
+ /// Final chunk to complete the output (may be empty).
+ public abstract string Flush();
+}
diff --git a/src/AI/src/Essentials.AI/Properties/AssemblyInfo.cs b/src/AI/src/Essentials.AI/Properties/AssemblyInfo.cs
new file mode 100644
index 000000000000..e0fee09092e4
--- /dev/null
+++ b/src/AI/src/Essentials.AI/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Microsoft.Maui.Essentials.AI.UnitTests")]
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-android/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-android/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-android/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-android/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..2181eb66f3e3
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-android/PublicAPI.Unshipped.txt
@@ -0,0 +1,8 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.ChatClientBase
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions!
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.set -> void
+Microsoft.Maui.Essentials.AI.Extensions
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-ios/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-ios/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-ios/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-ios/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..a2fe315abce7
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-ios/PublicAPI.Unshipped.txt
@@ -0,0 +1,15 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient() -> void
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient(Microsoft.Extensions.Logging.ILogger? logger) -> void
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient(Microsoft.Extensions.Logging.ILogger? logger) -> void
+Microsoft.Maui.Essentials.AI.ChatClientBase
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions!
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.set -> void
+Microsoft.Maui.Essentials.AI.Extensions
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+override Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+static Microsoft.Maui.Essentials.AI.Extensions.AddAppleIntelligenceChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-maccatalyst/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-maccatalyst/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-maccatalyst/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..a2fe315abce7
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt
@@ -0,0 +1,15 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient() -> void
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient(Microsoft.Extensions.Logging.ILogger? logger) -> void
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient(Microsoft.Extensions.Logging.ILogger? logger) -> void
+Microsoft.Maui.Essentials.AI.ChatClientBase
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions!
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.set -> void
+Microsoft.Maui.Essentials.AI.Extensions
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+override Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+static Microsoft.Maui.Essentials.AI.Extensions.AddAppleIntelligenceChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-macos/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-macos/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-macos/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-macos/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-macos/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..a2fe315abce7
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-macos/PublicAPI.Unshipped.txt
@@ -0,0 +1,15 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient() -> void
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient(Microsoft.Extensions.Logging.ILogger? logger) -> void
+Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.AppleIntelligenceChatClient(Microsoft.Extensions.Logging.ILogger? logger) -> void
+Microsoft.Maui.Essentials.AI.ChatClientBase
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions!
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.set -> void
+Microsoft.Maui.Essentials.AI.Extensions
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+override Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+override Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+static Microsoft.Maui.Essentials.AI.Extensions.AddAppleIntelligenceChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-windows/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-windows/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-windows/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net-windows/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..2181eb66f3e3
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net-windows/PublicAPI.Unshipped.txt
@@ -0,0 +1,8 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.ChatClientBase
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions!
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.set -> void
+Microsoft.Maui.Essentials.AI.Extensions
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/net/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/net/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..2181eb66f3e3
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/net/PublicAPI.Unshipped.txt
@@ -0,0 +1,8 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.ChatClientBase
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.get -> System.Text.Json.JsonSerializerOptions!
+Microsoft.Maui.Essentials.AI.ChatClientBase.JsonSerializerOptions.set -> void
+Microsoft.Maui.Essentials.AI.Extensions
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+abstract Microsoft.Maui.Essentials.AI.ChatClientBase.GetStreamingResponseAsync(System.Collections.Generic.IEnumerable! messages, Microsoft.Extensions.AI.ChatOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable!
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/PublicAPI/netstandard/PublicAPI.Shipped.txt b/src/AI/src/Essentials.AI/PublicAPI/netstandard/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/netstandard/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/AI/src/Essentials.AI/PublicAPI/netstandard/PublicAPI.Unshipped.txt b/src/AI/src/Essentials.AI/PublicAPI/netstandard/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..39b57273e270
--- /dev/null
+++ b/src/AI/src/Essentials.AI/PublicAPI/netstandard/PublicAPI.Unshipped.txt
@@ -0,0 +1,3 @@
+#nullable enable
+Microsoft.Maui.Essentials.AI.Extensions
+static Microsoft.Maui.Essentials.AI.Extensions.AddPlatformChatClient(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.Extensions.DependencyInjection.ServiceLifetime lifetime = Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/src/AI/src/Essentials.AI/README.md b/src/AI/src/Essentials.AI/README.md
new file mode 100644
index 000000000000..f712b5cc122b
--- /dev/null
+++ b/src/AI/src/Essentials.AI/README.md
@@ -0,0 +1,14 @@
+## Generating Files
+
+To generate the API definitions files:
+
+```
+dotnet build src/AI/src/Essentials.AI/Essentials.AI.csproj -f net10.0-ios26.0
+
+sharpie bind \
+ --output=src/AI/src/Essentials.AI/Platform/MaciOS \
+ --namespace=Microsoft.Maui.Essentials.AI \
+ --sdk=iphoneos26.1 \
+ --scope=. \
+ src/AI/src/Essentials.AI/Users/matthew/Documents/GitHub/maui/artifacts/obj/Essentials.AI/Debug/net10.0-ios26.0/xcode/EssentialsAI-485fe/archives/EssentialsAIiOS.xcarchive/Products/Library/Frameworks/EssentialsAI.framework/Headers/EssentialsAI-Swift.h
+```
\ No newline at end of file
diff --git a/src/AI/tests/Essentials.AI.Benchmarks/Essentials.AI.Benchmarks.csproj b/src/AI/tests/Essentials.AI.Benchmarks/Essentials.AI.Benchmarks.csproj
new file mode 100644
index 000000000000..6ca19dbafe79
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.Benchmarks/Essentials.AI.Benchmarks.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ Release
+ net10.0
+ Essentials.AI.Benchmarks
+ Microsoft.Maui.Essentials.AI.Benchmarks
+ false
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+
+
diff --git a/src/AI/tests/Essentials.AI.Benchmarks/Models.cs b/src/AI/tests/Essentials.AI.Benchmarks/Models.cs
new file mode 100644
index 000000000000..2fde6e72e222
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.Benchmarks/Models.cs
@@ -0,0 +1,38 @@
+using System.Collections.Generic;
+
+namespace Maui.Controls.Sample.Models;
+
+public class TravelItinerary
+{
+ public string? Title { get; set; }
+ public string? Destination { get; set; }
+ public string? Description { get; set; }
+ public List? Days { get; set; }
+}
+
+public class DayItinerary
+{
+ public int Day { get; set; }
+ public string? Date { get; set; }
+ public string? Summary { get; set; }
+ public List? Activities { get; set; }
+}
+
+public class Activity
+{
+ public string? Time { get; set; }
+ public string? Title { get; set; }
+ public string? Description { get; set; }
+ public ActivityType Type { get; set; }
+}
+
+public enum ActivityType
+{
+ Breakfast,
+ Sightseeing,
+ Lunch,
+ Adventure,
+ Dinner,
+ Cultural,
+ Leisure
+}
diff --git a/src/AI/tests/Essentials.AI.Benchmarks/Program.cs b/src/AI/tests/Essentials.AI.Benchmarks/Program.cs
new file mode 100644
index 000000000000..bac0be2bcdb8
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.Benchmarks/Program.cs
@@ -0,0 +1,11 @@
+using BenchmarkDotNet.Running;
+
+namespace Microsoft.Maui.Essentials.AI.Benchmarks;
+
+class Program
+{
+ static void Main(string[] args)
+ {
+ BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+ }
+}
diff --git a/src/AI/tests/Essentials.AI.Benchmarks/StreamingJsonDeserializerBenchmark.cs b/src/AI/tests/Essentials.AI.Benchmarks/StreamingJsonDeserializerBenchmark.cs
new file mode 100644
index 000000000000..fe705f6f54cf
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.Benchmarks/StreamingJsonDeserializerBenchmark.cs
@@ -0,0 +1,60 @@
+using System;
+using System.IO;
+using System.Text.Json;
+using BenchmarkDotNet.Attributes;
+using Maui.Controls.Sample.Models;
+using Maui.Controls.Sample.Services;
+
+namespace Microsoft.Maui.Essentials.AI.Benchmarks;
+
+[MemoryDiagnoser]
+[GcServer(true)]
+[SimpleJob(iterationCount: 20, warmupCount: 5)]
+public class StreamingJsonDeserializerBenchmark
+{
+ private string[]? _chunks;
+ private JsonSerializerOptions? _options;
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ // Read the test data stream
+ var streamPath = Path.Combine(AppContext.BaseDirectory, "maui-itinerary-1.txt");
+ var streamContent = File.ReadAllText(streamPath);
+ _chunks = streamContent.Split('\n', StringSplitOptions.RemoveEmptyEntries);
+
+ _options = new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ AllowTrailingCommas = true
+ };
+ }
+
+ [Benchmark(Description = "StreamingJsonDeserializer (skipDeserialization=true)")]
+ public void NewUtf8JsonWriterSkipDeserializationApproach()
+ {
+ var deserializer = new StreamingJsonDeserializer(_options, skipDeserialization: true);
+
+ TravelItinerary? lastModel = null;
+ foreach (var chunk in _chunks!)
+ {
+ var model = deserializer.ProcessChunk(chunk);
+ if (model != null)
+ lastModel = model;
+ }
+ }
+
+ [Benchmark(Description = "StreamingJsonDeserializer (skipDeserialization=false)")]
+ public void OldStringBuilderSkipDeserializationApproach()
+ {
+ var deserializer = new StreamingJsonDeserializer(_options, skipDeserialization: false);
+
+ TravelItinerary? lastModel = null;
+ foreach (var chunk in _chunks!)
+ {
+ var model = deserializer.ProcessChunk(chunk);
+ if (model != null)
+ lastModel = model;
+ }
+ }
+}
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Directory.Build.targets b/src/AI/tests/Essentials.AI.DeviceTests/Directory.Build.targets
new file mode 100644
index 000000000000..ee0f29e9005c
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Directory.Build.targets
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj b/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
new file mode 100644
index 000000000000..c73e12921329
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Essentials.AI.DeviceTests.csproj
@@ -0,0 +1,55 @@
+
+
+
+ $(MauiDeviceTestsPlatforms)
+ Exe
+ true
+ Microsoft.Maui.Essentials.AI.DeviceTests
+ Microsoft.Maui.Essentials.AI.DeviceTests
+
+ maccatalyst-x64
+ maccatalyst-arm64
+ android-arm64;android-x64
+ true
+ enable
+ enable
+
+
+
+ AI Tests
+ com.microsoft.maui.ai.devicetests
+ 1
+ 1.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/MauiProgram.cs b/src/AI/tests/Essentials.AI.DeviceTests/MauiProgram.cs
new file mode 100644
index 000000000000..f7ca7b6ccd5e
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/MauiProgram.cs
@@ -0,0 +1,27 @@
+using Microsoft.Maui.Hosting;
+using Microsoft.Maui.TestUtils.DeviceTests.Runners;
+
+namespace Microsoft.Maui.Essentials.AI.DeviceTests;
+
+public static class MauiProgram
+{
+ public static MauiApp CreateMauiApp()
+ {
+ var appBuilder = MauiApp.CreateBuilder();
+ appBuilder
+ .ConfigureTests(new TestOptions
+ {
+ Assemblies =
+ {
+ typeof(MauiProgram).Assembly
+ },
+ })
+ .UseHeadlessRunner(new HeadlessRunnerOptions
+ {
+ RequiresUIContext = true,
+ })
+ .UseVisualRunner();
+
+ return appBuilder.Build();
+ }
+}
\ No newline at end of file
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Android/AndroidManifest.xml b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Android/AndroidManifest.xml
new file mode 100644
index 000000000000..a4f8550d786e
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Android/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/MacCatalyst/Info.plist b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/MacCatalyst/Info.plist
new file mode 100644
index 000000000000..c96dd0a22544
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/MacCatalyst/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ UIDeviceFamily
+
+ 1
+ 2
+
+ UIRequiredDeviceCapabilities
+
+ arm64
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ XSAppIconAssets
+ Assets.xcassets/appicon.appiconset
+
+
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/App.xaml b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/App.xaml
new file mode 100644
index 000000000000..4a1877bd2b52
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/App.xaml
@@ -0,0 +1,5 @@
+
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/App.xaml.cs b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/App.xaml.cs
new file mode 100644
index 000000000000..4fcf629b9a40
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/App.xaml.cs
@@ -0,0 +1,10 @@
+using Microsoft.Maui.Hosting;
+
+namespace Microsoft.Maui.Essentials.AI.DeviceTests.WinUI;
+
+public partial class App : MauiWinUIApplication
+{
+ public App() => InitializeComponent();
+
+ protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
+}
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/Package.appxmanifest b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/Package.appxmanifest
new file mode 100644
index 000000000000..afa2dcd56da0
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/Package.appxmanifest
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+ $placeholder$
+ Microsoft
+ $placeholder$.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/app.manifest b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/app.manifest
new file mode 100644
index 000000000000..f9ef30446a98
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/Windows/app.manifest
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ true/PM
+ PerMonitorV2, PerMonitor
+
+
+
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Platforms/iOS/Info.plist b/src/AI/tests/Essentials.AI.DeviceTests/Platforms/iOS/Info.plist
new file mode 100644
index 000000000000..0004a4fdee5d
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/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/src/AI/tests/Essentials.AI.DeviceTests/Resources/Raw/dotnet_bot.png b/src/AI/tests/Essentials.AI.DeviceTests/Resources/Raw/dotnet_bot.png
new file mode 100644
index 000000000000..1096e673e93e
Binary files /dev/null and b/src/AI/tests/Essentials.AI.DeviceTests/Resources/Raw/dotnet_bot.png differ
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Resources/appicon.svg b/src/AI/tests/Essentials.AI.DeviceTests/Resources/appicon.svg
new file mode 100644
index 000000000000..9d63b6513a1c
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Resources/appicon.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Resources/appiconfg.svg b/src/AI/tests/Essentials.AI.DeviceTests/Resources/appiconfg.svg
new file mode 100644
index 000000000000..21dfb25f187b
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Resources/appiconfg.svg
@@ -0,0 +1,8 @@
+
+
+
\ No newline at end of file
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Tests/ChatClientCancellationTests.cs b/src/AI/tests/Essentials.AI.DeviceTests/Tests/ChatClientCancellationTests.cs
new file mode 100644
index 000000000000..cb6d896f7a27
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Tests/ChatClientCancellationTests.cs
@@ -0,0 +1,193 @@
+using Microsoft.Extensions.AI;
+using Xunit;
+
+#if IOS || MACCATALYST
+using PlatformChatClient = Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient;
+#elif ANDROID
+using PlatformChatClient = Microsoft.Maui.Essentials.AI.MLKitGenAIChatClient;
+#elif WINDOWS
+using PlatformChatClient = Microsoft.Maui.Essentials.AI.WindowsAIChatClient;
+#endif
+
+namespace Microsoft.Maui.Essentials.AI.DeviceTests;
+
+public class ChatClientCancellationTests
+{
+ [Fact]
+ public async Task GetResponseAsync_AcceptsCancellationToken()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Hello")
+ };
+
+ using var cts = new CancellationTokenSource();
+
+ await client.GetResponseAsync(messages, cancellationToken: cts.Token);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_WithCanceledToken_ThrowsOrCompletesGracefully()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Hello")
+ };
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ try
+ {
+ await client.GetResponseAsync(messages, cancellationToken: cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected behavior
+ }
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_CancelAfterStart_HandlesGracefully()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Tell me a long story")
+ };
+
+ using var cts = new CancellationTokenSource();
+
+ var task = client.GetResponseAsync(messages, cancellationToken: cts.Token);
+
+ await Task.Delay(50);
+ cts.Cancel();
+
+ try
+ {
+ await task;
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected behavior
+ }
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_WithTimeout_CompletesOrThrows()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Hello")
+ };
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+
+ try
+ {
+ await client.GetResponseAsync(messages, cancellationToken: cts.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected if operation times out
+ }
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_AcceptsCancellationToken()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Hello")
+ };
+
+ using var cts = new CancellationTokenSource();
+
+ await foreach (var update in client.GetStreamingResponseAsync(messages, cancellationToken: cts.Token))
+ {
+ // Process updates
+ }
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_WithCanceledToken_ThrowsOrCompletesGracefully()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Hello")
+ };
+
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ try
+ {
+ await foreach (var update in client.GetStreamingResponseAsync(messages, cancellationToken: cts.Token))
+ {
+ // Should not reach here
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected behavior
+ }
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_CancelDuringStreaming_HandlesGracefully()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Count to 100")
+ };
+
+ using var cts = new CancellationTokenSource();
+
+ var updateCount = 0;
+ try
+ {
+ await foreach (var update in client.GetStreamingResponseAsync(messages, cancellationToken: cts.Token))
+ {
+ updateCount++;
+ if (updateCount >= 2)
+ {
+ cts.Cancel();
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected behavior
+ }
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_WithTimeout_CompletesOrThrows()
+ {
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "Count to 100")
+ };
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+
+ try
+ {
+ await foreach (var update in client.GetStreamingResponseAsync(messages, cancellationToken: cts.Token))
+ {
+ // Process updates until timeout
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected if operation times out
+ }
+ }
+}
diff --git a/src/AI/tests/Essentials.AI.DeviceTests/Tests/ChatClientFunctionCallingTests.cs b/src/AI/tests/Essentials.AI.DeviceTests/Tests/ChatClientFunctionCallingTests.cs
new file mode 100644
index 000000000000..c4465fc97bd5
--- /dev/null
+++ b/src/AI/tests/Essentials.AI.DeviceTests/Tests/ChatClientFunctionCallingTests.cs
@@ -0,0 +1,622 @@
+using Microsoft.Extensions.AI;
+using Xunit;
+
+#if IOS || MACCATALYST
+using PlatformChatClient = Microsoft.Maui.Essentials.AI.AppleIntelligenceChatClient;
+#elif ANDROID
+using PlatformChatClient = Microsoft.Maui.Essentials.AI.MLKitGenAIChatClient;
+#elif WINDOWS
+using PlatformChatClient = Microsoft.Maui.Essentials.AI.WindowsAIChatClient;
+#endif
+
+namespace Microsoft.Maui.Essentials.AI.DeviceTests;
+
+public class ChatClientFunctionCallingTests
+{
+ [Fact]
+ public async Task GetResponseAsync_CallsFunctionAndReturnsResult()
+ {
+ bool functionWasCalled = false;
+ string? capturedLocation = null;
+
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) =>
+ {
+ functionWasCalled = true;
+ capturedLocation = location;
+ return $"Sunny, 72°F in {location}";
+ },
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in Seattle?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool]
+ };
+
+ var response = await client.GetResponseAsync(messages, options);
+
+ Assert.NotNull(response);
+ Assert.True(functionWasCalled, "Function should have been called");
+ Assert.NotNull(capturedLocation);
+ Assert.Contains("Seattle", capturedLocation, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_HandlesMultipleFunctionCalls()
+ {
+ int weatherCallCount = 0;
+ int timeCallCount = 0;
+
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) =>
+ {
+ weatherCallCount++;
+ return $"Sunny, 72°F in {location}";
+ },
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var timeTool = AIFunctionFactory.Create(
+ (string timezone) =>
+ {
+ timeCallCount++;
+ return $"Current time in {timezone} is 10:30 AM";
+ },
+ name: "GetTime",
+ description: "Gets the current time for a timezone");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in Seattle and what time is it in PST?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool, timeTool]
+ };
+
+ var response = await client.GetResponseAsync(messages, options);
+
+ Assert.NotNull(response);
+ Assert.True(weatherCallCount > 0 || timeCallCount > 0, "At least one function should have been called");
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_CallsFunctionAndStreamsUpdates()
+ {
+ bool functionWasCalled = false;
+ string? capturedLocation = null;
+
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) =>
+ {
+ functionWasCalled = true;
+ capturedLocation = location;
+ return $"Sunny, 72°F in {location}";
+ },
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in Boston?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool]
+ };
+
+ bool receivedAnyUpdate = false;
+ await foreach (var update in client.GetStreamingResponseAsync(messages, options))
+ {
+ receivedAnyUpdate = true;
+ Assert.NotNull(update);
+ }
+
+ Assert.True(receivedAnyUpdate, "Should receive at least one streaming update");
+ Assert.True(functionWasCalled, "Function should have been called during streaming");
+ Assert.NotNull(capturedLocation);
+ Assert.Contains("Boston", capturedLocation, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_StreamsToolCallContent()
+ {
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) => $"Sunny, 72°F in {location}",
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in Chicago?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool]
+ };
+
+ bool foundToolCallContent = false;
+ var updates = new List();
+
+ await foreach (var update in client.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+
+ if (update.Contents.Any(c => c is FunctionCallContent))
+ {
+ foundToolCallContent = true;
+ }
+ }
+
+ Assert.True(updates.Count > 0, "Should receive streaming updates");
+ Assert.True(foundToolCallContent, "Should receive at least one update with FunctionCallContent");
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_StreamsToolResultContent()
+ {
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) => $"Sunny, 72°F in {location}",
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in Denver?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool]
+ };
+
+ bool foundToolResultContent = false;
+ var updates = new List();
+
+ await foreach (var update in client.GetStreamingResponseAsync(messages, options))
+ {
+ updates.Add(update);
+
+ if (update.Contents.Any(c => c is FunctionResultContent))
+ {
+ foundToolResultContent = true;
+ }
+ }
+
+ Assert.True(updates.Count > 0, "Should receive streaming updates");
+ Assert.True(foundToolResultContent, "Should receive at least one update with FunctionResultContent");
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_StreamsToolCallBeforeToolResult()
+ {
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) => $"Sunny, 72°F in {location}",
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in Miami?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool]
+ };
+
+ int? toolCallIndex = null;
+ int? toolResultIndex = null;
+ int currentIndex = 0;
+
+ await foreach (var update in client.GetStreamingResponseAsync(messages, options))
+ {
+ if (update.Contents.Any(c => c is FunctionCallContent) && toolCallIndex == null)
+ {
+ toolCallIndex = currentIndex;
+ }
+
+ if (update.Contents.Any(c => c is FunctionResultContent) && toolResultIndex == null)
+ {
+ toolResultIndex = currentIndex;
+ }
+
+ currentIndex++;
+ }
+
+ if (toolCallIndex.HasValue && toolResultIndex.HasValue)
+ {
+ Assert.True(toolCallIndex < toolResultIndex,
+ "FunctionCallContent should be streamed before FunctionResultContent");
+ }
+ }
+
+ [Fact]
+ public async Task GetStreamingResponseAsync_HandlesMultipleFunctionCalls()
+ {
+ int weatherCallCount = 0;
+ int timeCallCount = 0;
+
+ var weatherTool = AIFunctionFactory.Create(
+ (string location) =>
+ {
+ weatherCallCount++;
+ return $"Sunny, 72°F in {location}";
+ },
+ name: "GetWeather",
+ description: "Gets the weather for a location");
+
+ var timeTool = AIFunctionFactory.Create(
+ (string timezone) =>
+ {
+ timeCallCount++;
+ return $"Current time in {timezone} is 10:30 AM";
+ },
+ name: "GetTime",
+ description: "Gets the current time for a timezone");
+
+ var client = new PlatformChatClient();
+ var messages = new List
+ {
+ new(ChatRole.User, "What's the weather in New York and what time is it in EST?")
+ };
+ var options = new ChatOptions
+ {
+ Tools = [weatherTool, timeTool]
+ };
+
+ bool receivedUpdates = false;
+ await foreach (var update in client.GetStreamingResponseAsync(messages, options))
+ {
+ receivedUpdates = true;
+ }
+
+ Assert.True(receivedUpdates, "Should receive streaming updates");
+ Assert.True(weatherCallCount > 0 || timeCallCount > 0, "At least one function should have been called");
+ }
+
+ [Fact]
+ public async Task GetResponseAsync_FunctionWithComplexParameters()
+ {
+ bool functionWasCalled = false;
+
+ var searchTool = AIFunctionFactory.Create(
+ (string query, int maxResults, bool includeImages) =>
+ {
+ functionWasCalled = true;
+ return $"Found {maxResults} results for '{query}' (images: {includeImages})";
+ },
+ name: "Search",
+ description: "Searches for information");
+
+ var client = new PlatformChatClient();
+ var messages = new List