diff --git a/src/Primitives/CrestApps.Core.AI.Documents/DocumentFileSystemFileStoreOptionsConfiguration.cs b/src/Primitives/CrestApps.Core.AI.Documents/DocumentFileSystemFileStoreOptionsConfiguration.cs index c1844277..b5c17805 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/DocumentFileSystemFileStoreOptionsConfiguration.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/DocumentFileSystemFileStoreOptionsConfiguration.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; @@ -8,15 +9,22 @@ namespace CrestApps.Core.AI.Documents; /// public sealed class DocumentFileSystemFileStoreOptionsConfiguration : IConfigureOptions { + private const string ConfigurationKey = "CrestApps:AI:Documents:BasePath"; + private readonly IHostEnvironment _env; + private readonly IConfiguration _configuration; /// /// Initializes a new instance of the class. /// - /// The env. - public DocumentFileSystemFileStoreOptionsConfiguration(IHostEnvironment env) + /// The host environment. + /// The application configuration. + public DocumentFileSystemFileStoreOptionsConfiguration( + IHostEnvironment env, + IConfiguration configuration) { _env = env; + _configuration = configuration; } /// @@ -29,6 +37,11 @@ public void Configure(DocumentFileSystemFileStoreOptions options) var configuredBasePath = options.BasePath; + if (string.IsNullOrWhiteSpace(configuredBasePath)) + { + configuredBasePath = _configuration[ConfigurationKey]; + } + if (string.IsNullOrWhiteSpace(configuredBasePath)) { options.BasePath = Path.Combine(_env.ContentRootPath, "App_Data", "Documents"); diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs index 88a0ab6c..cc07c738 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs @@ -37,6 +37,13 @@ void WriteCrashEntry(string label, object data) WriteCrashEntry("Process exit signaled", $"Exit code: {Environment.ExitCode}"); }; +// When running under Visual Studio, all mutable data (database, logs, documents, +// site settings) must be stored outside the project source tree. VS monitors the +// source directory for file changes, and any new file triggers VS to stop the debug +// session. Redirecting App_Data and document storage to a temp location avoids this. +var appDataBasePath = Path.Combine(Path.GetTempPath(), "CrestApps", "AppData"); +var documentsBasePath = Path.Combine(Path.GetTempPath(), "CrestApps", "Documents"); + var builder = DistributedApplication.CreateBuilder(args); builder.Services.Configure(options => @@ -63,12 +70,22 @@ void WriteCrashEntry(string label, object data) .WithHttpsEndpoint(5001, name: "HttpsMvcWeb") .WithEnvironment((options) => { + var mvcAppData = Path.Combine(appDataBasePath, "MvcWeb"); + options.EnvironmentVariables.Add("CrestApps__AppDataPath", mvcAppData); + options.EnvironmentVariables.Add("CRESTAPPS_LOG_DIR", Path.Combine(mvcAppData, "logs")); + options.EnvironmentVariables.Add("CrestApps__AI__Documents__BasePath", documentsBasePath); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__DefaultDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__Endpoint", "http://localhost:11434"); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__ChatDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__MvcApp__MCP__Server__AuthenticationType", "None"); options.EnvironmentVariables.Add("CrestApps__MvcApp__A2A__Host__AuthenticationType", "None"); options.EnvironmentVariables.Add("CrestApps__MvcApp__A2A__Host__ExposeAgentsAsSkill", "true"); + + // Prevent VS-injected startup hooks (BrowserRefresh, DeltaApplier, BrowserLink) + // from loading into child processes. These middlewares can interfere with + // multipart file uploads when running under VS + Aspire. + options.EnvironmentVariables["DOTNET_STARTUP_HOOKS"] = ""; + options.EnvironmentVariables["__ASPNETCORE_BROWSER_TOOLS"] = ""; }); var blazorWeb = builder.AddProject("BlazorWeb") @@ -78,12 +95,22 @@ void WriteCrashEntry(string label, object data) .WithHttpsEndpoint(5201, name: "HttpsBlazorWeb") .WithEnvironment((options) => { + var blazorAppData = Path.Combine(appDataBasePath, "BlazorWeb"); + options.EnvironmentVariables.Add("CrestApps__AppDataPath", blazorAppData); + options.EnvironmentVariables.Add("CRESTAPPS_LOG_DIR", Path.Combine(blazorAppData, "logs")); + options.EnvironmentVariables.Add("CrestApps__AI__Documents__BasePath", documentsBasePath); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__DefaultDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__Endpoint", "http://localhost:11434"); options.EnvironmentVariables.Add("CrestApps__AI__Providers__Ollama__Connections__Default__ChatDeploymentName", ollamaModelName); options.EnvironmentVariables.Add("CrestApps__BlazorApp__MCP__Server__AuthenticationType", "None"); options.EnvironmentVariables.Add("CrestApps__BlazorApp__A2A__Host__AuthenticationType", "None"); options.EnvironmentVariables.Add("CrestApps__BlazorApp__A2A__Host__ExposeAgentsAsSkill", "true"); + + // Prevent VS-injected startup hooks (BrowserRefresh, DeltaApplier, BrowserLink) + // from loading into child processes. These middlewares can interfere with + // multipart file uploads when running under VS + Aspire. + options.EnvironmentVariables["DOTNET_STARTUP_HOOKS"] = ""; + options.EnvironmentVariables["__ASPNETCORE_BROWSER_TOOLS"] = ""; }); builder.AddProject("McpClientSample") @@ -112,6 +139,35 @@ void WriteCrashEntry(string label, object data) var app = builder.Build(); +// Open the Aspire Dashboard in the default browser after the app starts. +// We do this from code instead of using launchBrowser:true in launchSettings because +// VS attaches a browser management connection to browsers it launches, and that +// connection crashes when a native file dialog (e.g. file upload picker) opens. +// Opening the browser from code means VS has no management link to it. +var dashboardUrl = builder.Configuration["ASPNETCORE_URLS"]?.Split(';') + .FirstOrDefault(u => u.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + ?? builder.Configuration["ASPNETCORE_URLS"]?.Split(';').FirstOrDefault(); + +var lifetime = app.Services.GetRequiredService(); +lifetime.ApplicationStarted.Register(() => +{ + if (!string.IsNullOrEmpty(dashboardUrl)) + { + try + { + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo + { + FileName = dashboardUrl, + UseShellExecute = true, + }); + } + catch + { + // Non-critical: browser open is best-effort. + } + } +}); + try { await app.RunAsync(); diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json b/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json index 2a2da7c0..f89ce036 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json @@ -4,33 +4,29 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, + "hotReloadEnabled": false, "applicationUrl": "https://localhost:17260;http://localhost:15207", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_URLS": "https://localhost:17260;http://localhost:15207", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21194", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22004", - "DOTNET_DbgEnableMiniDump": "1", - "DOTNET_DbgMiniDumpType": "4", - "DOTNET_CreateDumpDiagnostics": "1" + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22004" } }, "http": { "commandName": "Project", "dotnetRunMessages": true, - "launchBrowser": true, + "launchBrowser": false, + "hotReloadEnabled": false, "applicationUrl": "http://localhost:15207", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "ASPNETCORE_URLS": "http://localhost:15207", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19030", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20028", - "DOTNET_DbgEnableMiniDump": "1", - "DOTNET_DbgMiniDumpType": "4", - "DOTNET_CreateDumpDiagnostics": "1" + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20028" } } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/nlog.config b/src/Startup/CrestApps.Core.Blazor.Web/nlog.config index a4f69f49..b22710cb 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/nlog.config +++ b/src/Startup/CrestApps.Core.Blazor.Web/nlog.config @@ -4,13 +4,13 @@ autoReload="true" throwConfigExceptions="true" internalLogLevel="Warn" - internalLogFile="App_Data/logs/nlog-internal.log"> + internalLogFile="${environment:variable=CRESTAPPS_LOG_DIR:whenEmpty=${aspnet-appbasepath}/App_Data/logs}/nlog-internal.log"> - + + internalLogFile="${environment:variable=CRESTAPPS_LOG_DIR:whenEmpty=${aspnet-appbasepath}/App_Data/logs}/nlog-internal.log"> - + /// Applies the shared sample-host infrastructure used by the MVC and Blazor /// sample applications and returns the resolved App_Data path. @@ -29,10 +31,24 @@ public static string AddSharedSampleHostDefaults(this WebApplicationBuilder buil builder.Logging.ClearProviders(); builder.WebHost.UseNLog(); - var appDataPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); + // Allow the App_Data path to be overridden via configuration (e.g. an environment + // variable CrestApps__AppDataPath). When running under Aspire + Visual Studio the + // content root points to the project source directory, and any file writes there + // can trigger VS to stop the debug session. Redirecting App_Data outside the + // source tree avoids that problem. + var configuredAppDataPath = builder.Configuration[AppDataPathConfigurationKey]; + + var appDataPath = !string.IsNullOrWhiteSpace(configuredAppDataPath) + ? configuredAppDataPath + : Path.Combine(builder.Environment.ContentRootPath, "App_Data"); + Directory.CreateDirectory(appDataPath); - builder.Configuration.AddJsonFile("App_Data/appsettings.json", optional: true, reloadOnChange: false); + builder.Configuration.AddJsonFile( + Path.Combine(appDataPath, "appsettings.json"), + optional: true, + reloadOnChange: false); + builder.Services.AddSharedSiteSettings(appDataPath); return appDataPath; diff --git a/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs b/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs index b76f87dc..cf5b2fec 100644 --- a/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Documents/DocumentFileStoreRegistrationTests.cs @@ -1,5 +1,6 @@ using System.Text; using CrestApps.Core.AI.Documents; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; @@ -17,6 +18,7 @@ public async Task AddCoreAIDocumentProcessing_RegistersDefaultFileSystemStore() { var services = new ServiceCollection() .AddSingleton(new TestHostEnvironment(contentRoot)) + .AddSingleton(new ConfigurationBuilder().Build()) .AddLogging() .AddCoreAIDocumentProcessing(); await using var provider = services.BuildServiceProvider(); @@ -46,6 +48,7 @@ public async Task AddCoreAIDocumentProcessing_UsesConfiguredFileSystemBasePath() { var services = new ServiceCollection() .AddSingleton(new TestHostEnvironment(contentRoot)) + .AddSingleton(new ConfigurationBuilder().Build()) .Configure(options => options.BasePath = "CustomDocuments") .AddLogging() .AddCoreAIDocumentProcessing(); @@ -75,6 +78,7 @@ public async Task AddCoreAIDocumentProcessing_PostConfigureOverridesDefaultBaseP { var services = new ServiceCollection() .AddSingleton(new TestHostEnvironment(contentRoot)) + .AddSingleton(new ConfigurationBuilder().Build()) .AddSingleton>( new PostConfigureOptions(Options.DefaultName, options => {