diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs index e793d19d..c7255c98 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Json/AIProviderConnectionJsonConverter.cs @@ -24,6 +24,7 @@ public override AIProviderConnection Read(ref Utf8JsonReader reader, Type typeTo ?? GetString(node, "ProviderName"), Name = GetString(node, nameof(AIProviderConnection.Name)), DisplayText = GetString(node, nameof(AIProviderConnection.DisplayText)), + IsReadOnly = GetBoolean(node, nameof(AIProviderConnection.IsReadOnly)), CreatedUtc = GetDateTime(node, nameof(AIProviderConnection.CreatedUtc)), Author = GetString(node, nameof(AIProviderConnection.Author)), OwnerId = GetString(node, nameof(AIProviderConnection.OwnerId)), @@ -58,6 +59,7 @@ public override void Write(Utf8JsonWriter writer, AIProviderConnection value, Js WriteString(writer, nameof(AIProviderConnection.ClientName), value.ClientName); WriteString(writer, nameof(AIProviderConnection.Name), value.Name); WriteString(writer, nameof(AIProviderConnection.DisplayText), value.DisplayText); + writer.WriteBoolean(nameof(AIProviderConnection.IsReadOnly), value.IsReadOnly); writer.WriteString(nameof(AIProviderConnection.CreatedUtc), value.CreatedUtc); WriteString(writer, nameof(AIProviderConnection.Author), value.Author); WriteString(writer, nameof(AIProviderConnection.OwnerId), value.OwnerId); @@ -87,6 +89,16 @@ private static string GetString(JsonObject node, string name) return null; } + private static bool GetBoolean(JsonObject node, string name) + { + if (node.TryGetPropertyValue(name, out var value) && value != null) + { + return value.GetValue(); + } + + return false; + } + private static DateTime GetDateTime(JsonObject node, string name) { if (node.TryGetPropertyValue(name, out var value) && value != null) diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs index a2286366..3925297e 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIDeployment.cs @@ -59,6 +59,12 @@ public string ModelName public string OwnerId { get; set; } + /// + /// Gets or sets whether this catalog item is read-only. + /// Configuration-backed entries are read-only and cannot be modified or deleted through the UI. + /// + public bool IsReadOnly { get; set; } + public bool SupportsType(AIDeploymentType type) { return Type.Supports(type); @@ -74,6 +80,7 @@ public AIDeployment Clone() Source = Source, ConnectionName = ConnectionName, Type = Type, + IsReadOnly = IsReadOnly, CreatedUtc = CreatedUtc, Author = Author, OwnerId = OwnerId, diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs index 776dabbc..87c9f819 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/AIProviderConnection.cs @@ -37,6 +37,12 @@ public string ProviderName public string OwnerId { get; set; } + /// + /// Gets or sets whether this catalog item is read-only. + /// Configuration-backed entries are read-only and cannot be modified or deleted through the UI. + /// + public bool IsReadOnly { get; set; } + public AIProviderConnection Clone() { return new AIProviderConnection @@ -45,6 +51,7 @@ public AIProviderConnection Clone() Source = Source, Name = Name, DisplayText = DisplayText, + IsReadOnly = IsReadOnly, CreatedUtc = CreatedUtc, Author = Author, OwnerId = OwnerId, diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs index cf4ae38a..3b32236d 100644 --- a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs +++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityExtensions.cs @@ -9,15 +9,33 @@ namespace CrestApps.Core; /// public static class ExtensibleEntityExtensions { - private static readonly JsonSerializerOptions _jsonOptions = new() + private static JsonSerializerOptions _jsonOptions = ExtensibleEntityJsonOptions.CreateDefaultSerializerOptions(); + + /// + /// Gets or sets the used for serializing and + /// deserializing extensible entity properties. + /// + /// + /// This property is initialized with sensible defaults. To customize, either: + /// + /// Set this property directly at application startup before any serialization occurs. + /// Use the DI options pattern with (requires CrestApps.Core). + /// + /// + public static JsonSerializerOptions JsonSerializerOptions { - PropertyNameCaseInsensitive = true, - }; + get => _jsonOptions; + set + { + ArgumentNullException.ThrowIfNull(value); + _jsonOptions = value; + } + } /// /// Gets a strongly-typed object stored in the entity's properties. /// - public static T As(this ExtensibleEntity entity) + public static T GetOrCreate(this ExtensibleEntity entity) where T : new() { ArgumentNullException.ThrowIfNull(entity); @@ -73,7 +91,7 @@ public static ExtensibleEntity Put(this ExtensibleEntity entity, string name, ob /// Returns true if a non-null value was found and deserialized. /// public static bool TryGet(this ExtensibleEntity entity, out T result) - where T : class, new() + where T : class { ArgumentNullException.ThrowIfNull(entity); @@ -109,7 +127,7 @@ public static ExtensibleEntity Alter(this ExtensibleEntity entity, Action ArgumentNullException.ThrowIfNull(entity); ArgumentNullException.ThrowIfNull(alter); - var obj = entity.As(); + var obj = entity.GetOrCreate(); alter(obj); entity.Put(obj); diff --git a/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityJsonOptions.cs b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityJsonOptions.cs new file mode 100644 index 00000000..14887b38 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Abstractions/ExtensibleEntityJsonOptions.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CrestApps.Core; + +/// +/// Options for configuring the used by +/// when serializing and deserializing +/// extensible entity properties. +/// +/// +/// Register this class with the DI options pattern to customize serialization behavior: +/// +/// services.Configure<ExtensibleEntityJsonOptions>(options => +/// { +/// options.SerializerOptions.Converters.Add(new MyCustomConverter()); +/// }); +/// +/// +public sealed class ExtensibleEntityJsonOptions +{ + /// + /// Gets or sets the used for serializing and + /// deserializing extensible entity properties. + /// + public JsonSerializerOptions SerializerOptions { get; set; } = CreateDefaultSerializerOptions(); + + /// + /// Creates a new instance with the default settings + /// used by . + /// + public static JsonSerializerOptions CreateDefaultSerializerOptions() => new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + ReferenceHandler = null, + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + WriteIndented = false, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Converters = + { + new JsonStringEnumConverter(), + }, + }; +} diff --git a/src/CrestApps.Core.Docs/docs/core/extensible-entity.md b/src/CrestApps.Core.Docs/docs/core/extensible-entity.md new file mode 100644 index 00000000..992abab2 --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/extensible-entity.md @@ -0,0 +1,132 @@ +--- +sidebar_label: ExtensibleEntity +sidebar_position: 3 +title: ExtensibleEntity & JSON Configuration +description: Learn how to use ExtensibleEntity for dynamic property storage and how to configure JSON serialization with ExtensibleEntityJsonOptions. +--- + +# ExtensibleEntity & JSON Configuration + +> Base class for entities that support dynamic, schema-free properties alongside their typed fields. + +## Overview + +`ExtensibleEntity` provides a `Properties` dictionary that allows you to attach arbitrary strongly-typed data to any entity without changing its schema. This powers features like AI profile metadata, chat session annotations, and custom application data. + +## Quick Reference + +| Method | When to Use | +|--------|-------------| +| `TryGet(out T result)` | **Read-only** access — returns `false` when the key is missing (zero allocations) | +| `GetOrCreate()` | **Read-write** access — creates a new `T` when the key is missing | +| `Alter(Action)` | Modify a stored object in-place (creates if missing) | +| `Put(T value)` | Store a strongly-typed object (key = type name) | +| `Put(string name, object value)` | Store a value under a custom key | +| `Has()` | Check whether a key exists | +| `Remove()` | Remove a stored object | + +### Prefer `TryGet` for Read-Only Access + +When you only need to **read** a property and do not need a default instance, use `TryGet`: + +```csharp +if (profile.TryGet(out var metadata)) +{ + // Use metadata — only entered when data is present. + Console.WriteLine(metadata.Label); +} +``` + +`GetOrCreate()` allocates a new `T` every time the key is absent. Reserve it for cases where you intend to write back: + +```csharp +var metadata = profile.GetOrCreate(); +metadata.Label = "Updated"; +profile.Put(metadata); +``` + +## Configuring JSON Serialization + +All `ExtensibleEntity` property reads and writes go through a shared `JsonSerializerOptions` instance. The defaults work for most scenarios, but you can customize them. + +### Default Settings + +| Setting | Default Value | +|---------|---------------| +| `DefaultIgnoreCondition` | `WhenWritingNull` | +| `PreferredObjectCreationHandling` | `Populate` | +| `PropertyNameCaseInsensitive` | `true` | +| `ReadCommentHandling` | `Skip` | +| `AllowTrailingCommas` | `true` | +| `WriteIndented` | `false` | +| `NumberHandling` | `AllowReadingFromString` | +| Built-in converters | `JsonStringEnumConverter` | + +### Using the Options Pattern (Recommended) + +When you use the CrestApps framework with `AddCrestAppsCore()`, register your customizations through the standard options pattern: + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddOpenAI() + ) +); + +// Add a custom JSON converter for ExtensibleEntity properties. +builder.Services.Configure(options => +{ + options.SerializerOptions.Converters.Add(new MyCustomJsonConverter()); +}); +``` + +The framework registers an `IHostedService` that reads `IOptions` at startup and pushes the configured `JsonSerializerOptions` to `ExtensibleEntityExtensions.JsonSerializerOptions`. Your customizations are applied before any request processing begins. + +### Direct Static Property (Advanced) + +If you are not using the CrestApps DI extensions (e.g., in a console app or test harness), set the static property directly **before any serialization occurs**: + +```csharp +var options = ExtensibleEntityJsonOptions.CreateDefaultSerializerOptions(); +options.Converters.Add(new MyCustomJsonConverter()); + +ExtensibleEntityExtensions.JsonSerializerOptions = options; +``` + +:::warning +`JsonSerializerOptions` becomes immutable after the first serialization call (System.Text.Json caches internal metadata). Configure it during application startup, before any `ExtensibleEntity` methods are invoked. +::: + +## Full Example + +```csharp +using CrestApps.Core; + +// Define a custom metadata type. +public sealed class InvoiceMetadata +{ + public string InvoiceNumber { get; set; } + public decimal Amount { get; set; } + public bool IsPaid { get; set; } +} + +// Store metadata on an entity. +entity.Put(new InvoiceMetadata +{ + InvoiceNumber = "INV-2025-001", + Amount = 149.99m, + IsPaid = false, +}); + +// Read-only check — no allocation if missing. +if (entity.TryGet(out var invoice)) +{ + Console.WriteLine($"Invoice {invoice.InvoiceNumber}: ${invoice.Amount}"); +} + +// Modify in-place. +entity.Alter(inv => +{ + inv.IsPaid = true; +}); +``` diff --git a/src/CrestApps.Core.Docs/sidebars.js b/src/CrestApps.Core.Docs/sidebars.js index 33e2e7f5..7dc5b230 100644 --- a/src/CrestApps.Core.Docs/sidebars.js +++ b/src/CrestApps.Core.Docs/sidebars.js @@ -12,6 +12,7 @@ const sidebars = { items: [ 'core/architecture', 'core/core-services', + 'core/extensible-entity', 'core/getting-started-aspnet', 'core/index', 'core/interfaces', diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs index ab47ed7e..03569a24 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/A2AAgentProxyTool.cs @@ -86,13 +86,10 @@ protected override async ValueTask InvokeCoreAsync( var connectionStore = arguments.Services.GetRequiredService>(); var connection = await connectionStore.FindByIdAsync(_connectionId); - - if (connection is not null) + if (connection is not null && connection.TryGet(out var metadata)) { - var metadata = connection.As(); await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken); } - } var client = new A2AClient(new Uri(_endpoint), httpClient); diff --git a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs index 58e5c382..ff40fd17 100644 --- a/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs +++ b/src/Primitives/CrestApps.Core.AI.A2A/Services/DefaultA2AAgentCardCacheService.cs @@ -33,13 +33,16 @@ public async Task GetAgentCardAsync(string connectionId, A2AConnectio try { var httpClient = _httpClientFactory.CreateClient(); - var metadata = connection.As(); - // Resolve the scoped auth service from the current request to avoid - // capturing a scoped service in this singleton. - var authService = _httpContextAccessor.HttpContext?.RequestServices.GetService(); - if (authService is not null) + + if (connection.TryGet(out var metadata)) { - await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken); + // Resolve the scoped auth service from the current request to avoid + // capturing a scoped service in this singleton. + var authService = _httpContextAccessor.HttpContext?.RequestServices.GetService(); + if (authService is not null) + { + await authService.ConfigureHttpClientAsync(httpClient, metadata, cancellationToken); + } } var resolver = new A2ACardResolver(new Uri(connection.Endpoint), httpClient); diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Endpoints/AIChatDocumentEndpoints.cs b/src/Primitives/CrestApps.Core.AI.Chat/Endpoints/AIChatDocumentEndpoints.cs index c026e0dc..5ea043e9 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Endpoints/AIChatDocumentEndpoints.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Endpoints/AIChatDocumentEndpoints.cs @@ -512,7 +512,7 @@ private static IReadOnlyList GetFiles(IFormCollection form) private static bool IsSessionDocumentUploadEnabled(AIProfile profile) { - return profile.As()?.AllowSessionDocuments == true; + return profile.TryGet(out var sessionDocMetadata) && sessionDocMetadata.AllowSessionDocuments; } private static async Task ResolveSessionDeploymentAsync(AIProfile profile, IAIDeploymentManager deploymentManager) diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs index 57ede604..6ed2b88f 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Handlers/ChatInteractionCompletionContextBuilderHandler.cs @@ -55,7 +55,11 @@ public Task BuiltAsync(AICompletionContextBuiltContext context) private async Task ResolveSystemMessageAsync(ChatInteraction interaction) { - var promptMetadata = interaction.As(); + if (!interaction.TryGet(out var promptMetadata)) + { + return interaction.SystemMessage; + } + var validTemplates = promptMetadata.Templates?.Where(selection => !string.IsNullOrWhiteSpace(selection.TemplateId)).ToList(); if (validTemplates is not { Count: > 0 }) { diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index 35cb0f9d..2df154fd 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -277,14 +277,17 @@ private static async Task GetAIGeneratedTitleAsync(IServiceProvider serv protected static string BuildTitleUserPrompt(AIProfile profile, string userPrompt) { var trimmedUserPrompt = userPrompt?.Trim(); - var profileMetadata = profile.As(); - var initialPrompt = profileMetadata.InitialPrompt?.Trim(); - if (string.IsNullOrWhiteSpace(initialPrompt)) + + if (profile.TryGet(out var profileMetadata)) { - return trimmedUserPrompt; + var initialPrompt = profileMetadata.InitialPrompt?.Trim(); + if (!string.IsNullOrWhiteSpace(initialPrompt)) + { + return string.IsNullOrWhiteSpace(trimmedUserPrompt) ? initialPrompt : $"{initialPrompt}\n\n{trimmedUserPrompt}"; + } } - return string.IsNullOrWhiteSpace(trimmedUserPrompt) ? initialPrompt : $"{initialPrompt}\n\n{trimmedUserPrompt}"; + return trimmedUserPrompt; } // ────────────── Session group management ────────────── @@ -1106,7 +1109,7 @@ protected virtual object CreateSessionPayload(AIChatSession chatSession, AIProfi Type = profile.Type.ToString(), }, chatSession.Documents, - Messages = prompts.Select(message => new AIChatResponseMessageDetailed { Id = message.ItemId, Role = message.Role.Value, IsGeneratedPrompt = message.IsGeneratedPrompt, Title = message.Title, Content = message.Content, UserRating = message.UserRating, References = message.References, Appearance = message.As(), }) + Messages = prompts.Select(message => new AIChatResponseMessageDetailed { Id = message.ItemId, Role = message.Role.Value, IsGeneratedPrompt = message.IsGeneratedPrompt, Title = message.Title, Content = message.Content, UserRating = message.UserRating, References = message.References, Appearance = message.TryGet(out var appearance) ? appearance : null, }) }; } diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs index 4ad27ba6..b7e52f02 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Services/AIChatSessionPostCloseProcessor.cs @@ -34,20 +34,24 @@ public AIChatSessionPostCloseProcessor( public static bool NeedsProcessing(AIProfile profile, AIChatSession chatSession) { var postSessionSettings = profile.GetSettings(); - var analyticsMetadata = profile.As(); var needsPostSessionTasks = !chatSession.IsPostSessionTasksProcessed && postSessionSettings.EnablePostSessionProcessing && postSessionSettings.PostSessionTasks.Count > 0; - var needsAnalytics = !chatSession.IsAnalyticsRecorded - && (analyticsMetadata.EnableSessionMetrics || analyticsMetadata.EnableAIResolutionDetection); + if (profile.TryGet(out var analyticsMetadata)) + { + var needsAnalytics = !chatSession.IsAnalyticsRecorded + && (analyticsMetadata.EnableSessionMetrics || analyticsMetadata.EnableAIResolutionDetection); + + var needsConversionGoals = !chatSession.IsConversionGoalsEvaluated + && analyticsMetadata.EnableConversionMetrics + && analyticsMetadata.ConversionGoals.Count > 0; - var needsConversionGoals = !chatSession.IsConversionGoalsEvaluated - && analyticsMetadata.EnableConversionMetrics - && analyticsMetadata.ConversionGoals.Count > 0; + return needsPostSessionTasks || needsAnalytics || needsConversionGoals; + } - return needsPostSessionTasks || needsAnalytics || needsConversionGoals; + return needsPostSessionTasks; } public async Task ProcessAsync( @@ -59,18 +63,23 @@ public async Task ProcessAsync( var result = new AIChatSessionPostCloseProcessingResult(); var postSessionSettings = profile.GetSettings(); - var analyticsMetadata = profile.As(); var needsPostSessionTasks = !chatSession.IsPostSessionTasksProcessed && postSessionSettings.EnablePostSessionProcessing && postSessionSettings.PostSessionTasks.Count > 0; - var needsAnalytics = !chatSession.IsAnalyticsRecorded - && (analyticsMetadata.EnableSessionMetrics || analyticsMetadata.EnableAIResolutionDetection); + var needsAnalytics = false; + var needsConversionGoals = false; - var needsConversionGoals = !chatSession.IsConversionGoalsEvaluated - && analyticsMetadata.EnableConversionMetrics - && analyticsMetadata.ConversionGoals.Count > 0; + if (profile.TryGet(out var analyticsMetadata)) + { + needsAnalytics = !chatSession.IsAnalyticsRecorded + && (analyticsMetadata.EnableSessionMetrics || analyticsMetadata.EnableAIResolutionDetection); + + needsConversionGoals = !chatSession.IsConversionGoalsEvaluated + && analyticsMetadata.EnableConversionMetrics + && analyticsMetadata.ConversionGoals.Count > 0; + } if (!needsPostSessionTasks && !needsAnalytics && !needsConversionGoals) { @@ -292,7 +301,7 @@ private async Task RecordSessionAnalyticsAsync( { var isResolved = false; - if (profile.As().EnableAIResolutionDetection) + if (profile.TryGet(out var analyticsMetadata) && analyticsMetadata.EnableAIResolutionDetection) { isResolved = await _postSessionProcessingService.EvaluateResolutionAsync(profile, prompts, cancellationToken); } diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs index ae7ce5ae..a6903872 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Handlers/CopilotOrchestrationContextHandler.cs @@ -17,8 +17,7 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) return Task.CompletedTask; } - var metadata = entity.As(); - if (metadata is not null) + if (entity.TryGet(out var metadata)) { context.Context.Properties[nameof(CopilotSessionMetadata)] = metadata; } diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs index 5e6dc1b1..332c300c 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/Services/CopilotOrchestrator.cs @@ -477,8 +477,7 @@ private async Task ConfigureMcpServersAsync(OrchestrationContext context, Sessio mcpDescription.Append(connection.DisplayText ?? connection.ItemId); if (connection.Source == McpConstants.TransportTypes.Sse) { - var sseMetadata = connection.As(); - if (sseMetadata?.Endpoint is not null) + if (connection.TryGet(out var sseMetadata) && sseMetadata.Endpoint is not null) { mcpDescription.Append(" (SSE: "); mcpDescription.Append(sseMetadata.Endpoint); @@ -487,8 +486,7 @@ private async Task ConfigureMcpServersAsync(OrchestrationContext context, Sessio } else if (connection.Source == McpConstants.TransportTypes.StdIo) { - var stdioMetadata = connection.As(); - if (!string.IsNullOrEmpty(stdioMetadata?.Command)) + if (connection.TryGet(out var stdioMetadata) && !string.IsNullOrEmpty(stdioMetadata.Command)) { mcpDescription.Append(" (StdIO: "); mcpDescription.Append(stdioMetadata.Command); diff --git a/src/Primitives/CrestApps.Core.AI.Ftp/Handlers/FtpResourceTypeHandler.cs b/src/Primitives/CrestApps.Core.AI.Ftp/Handlers/FtpResourceTypeHandler.cs index 5376e06e..9f3d5f91 100644 --- a/src/Primitives/CrestApps.Core.AI.Ftp/Handlers/FtpResourceTypeHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Ftp/Handlers/FtpResourceTypeHandler.cs @@ -27,8 +27,12 @@ public FtpResourceTypeHandler( protected override async Task GetResultAsync(McpResource resource, IReadOnlyDictionary variables, CancellationToken cancellationToken) { - var metadata = resource.As(); - var host = metadata?.Host; + if (!resource.TryGet(out var metadata)) + { + return CreateErrorResult(resource.Resource.Uri, "FTP connection metadata is missing."); + } + + var host = metadata.Host; if (string.IsNullOrEmpty(host)) { diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs index 7aa8ecce..5c7ee603 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Handlers/SseMcpConnectionSettingsHandler.cs @@ -36,7 +36,7 @@ private Task ProtectSensitiveFieldsAsync(McpConnection connection, JsonNode data } var protector = _dataProtectionProvider.CreateProtector(McpConstants.DataProtectionPurpose); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.ApiKey), value => metadata.ApiKey = value); ProtectField(protector, metadataNode, nameof(SseMcpConnectionMetadata.BasicPassword), value => metadata.BasicPassword = value); diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs index 32208561..b654471a 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/SseClientTransportProvider.cs @@ -25,8 +25,13 @@ public bool CanHandle(McpConnection connection) public async Task GetAsync(McpConnection connection) { - var metadata = connection.As(); + if (!connection.TryGet(out var metadata)) + { + return null; + } + var headers = await BuildHeadersAsync(metadata); + return new HttpClientTransport(new HttpClientTransportOptions { Endpoint = metadata.Endpoint, AdditionalHeaders = headers, }); } diff --git a/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs b/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs index e1f4e777..626f030a 100644 --- a/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs +++ b/src/Primitives/CrestApps.Core.AI.Mcp/Services/StdioClientTransportProvider.cs @@ -12,8 +12,13 @@ public bool CanHandle(McpConnection connection) public Task GetAsync(McpConnection connection) { - var metadata = connection.As(); + if (!connection.TryGet(out var metadata)) + { + return Task.FromResult(null); + } + var transport = new StdioClientTransport(new StdioClientTransportOptions { Name = connection.DisplayText, Command = metadata.Command, Arguments = metadata.Arguments, WorkingDirectory = metadata.WorkingDirectory, EnvironmentVariables = metadata.EnvironmentVariables, }); + return Task.FromResult(transport); } } \ No newline at end of file diff --git a/src/Primitives/CrestApps.Core.AI.Sftp/Handlers/SftpResourceTypeHandler.cs b/src/Primitives/CrestApps.Core.AI.Sftp/Handlers/SftpResourceTypeHandler.cs index 2bdc1f94..ae377326 100644 --- a/src/Primitives/CrestApps.Core.AI.Sftp/Handlers/SftpResourceTypeHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Sftp/Handlers/SftpResourceTypeHandler.cs @@ -27,8 +27,12 @@ public SftpResourceTypeHandler( protected override async Task GetResultAsync(McpResource resource, IReadOnlyDictionary variables, CancellationToken cancellationToken) { - var metadata = resource.As(); - var host = metadata?.Host; + if (!resource.TryGet(out var metadata)) + { + return CreateErrorResult(resource.Resource.Uri, "SFTP connection metadata is missing."); + } + + var host = metadata.Host; if (string.IsNullOrEmpty(host)) { diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs index d3712374..b4263836 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileCompletionContextBuilderHandler.cs @@ -26,15 +26,19 @@ public async Task BuildingAsync(AICompletionContextBuildingContext context) context.Context.ConnectionName = profile.GetLegacyConnectionName(); context.Context.ChatDeploymentName = profile.ChatDeploymentName; context.Context.UtilityDeploymentName = profile.UtilityDeploymentName; - var metadata = profile.As(); - context.Context.SystemMessage = await ResolveSystemMessageAsync(profile, metadata); - context.Context.Temperature = metadata.Temperature; - context.Context.TopP = metadata.TopP; - context.Context.FrequencyPenalty = metadata.FrequencyPenalty; - context.Context.PresencePenalty = metadata.PresencePenalty; - context.Context.MaxTokens = metadata.MaxTokens; - context.Context.PastMessagesCount = metadata.PastMessagesCount; - context.Context.UseCaching = metadata.UseCaching; + + if (profile.TryGet(out var metadata)) + { + context.Context.SystemMessage = await ResolveSystemMessageAsync(profile, metadata); + context.Context.Temperature = metadata.Temperature; + context.Context.TopP = metadata.TopP; + context.Context.FrequencyPenalty = metadata.FrequencyPenalty; + context.Context.PresencePenalty = metadata.PresencePenalty; + context.Context.MaxTokens = metadata.MaxTokens; + context.Context.PastMessagesCount = metadata.PastMessagesCount; + context.Context.UseCaching = metadata.UseCaching; + } + if (profile.TryGet(out var functionInvocationMetadata)) { context.Context.ToolNames = functionInvocationMetadata.Names; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs index 4ad7cf4d..201e2eb0 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIProfileHandler.cs @@ -24,7 +24,7 @@ public override Task UpdatingAsync(UpdatingContext context) private static Task PopulateAsync(AIProfile profile, JsonNode data) { - var metadata = profile.As(); + var metadata = profile.GetOrCreate(); var settings = profile.GetSettings(); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs index 17e3bf5b..a570dbba 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AgentOrchestrationContextBuilderHandler.cs @@ -46,8 +46,8 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) continue; } - var agentMetadata = agent.As(); - var isAlwaysAvailable = agentMetadata?.Availability == AgentAvailability.AlwaysAvailable; + var isAlwaysAvailable = agent.TryGet(out var agentMetadata) + && agentMetadata.Availability == AgentAvailability.AlwaysAvailable; if (isAlwaysAvailable || (requestedAgentNames is { Length: > 0 } && requestedAgentNames.Contains(agent.Name, StringComparer.OrdinalIgnoreCase))) { availableAgents.Add(new AgentInfo { Name = agent.Name, Description = agent.Description, }); diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DocumentOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DocumentOrchestrationHandler.cs index bbe74639..59522cca 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/DocumentOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DocumentOrchestrationHandler.cs @@ -52,9 +52,7 @@ public Task BuildingAsync(OrchestrationContextBuildingContext context) } else if (context.Resource is AIProfile profile) { - var documentsMetadata = profile.As(); - - if (documentsMetadata.Documents is { Count: > 0 }) + if (profile.TryGet(out var documentsMetadata) && documentsMetadata.Documents is { Count: > 0 }) { if (_logger.IsEnabled(LogLevel.Debug)) { @@ -85,8 +83,9 @@ public async Task BuiltAsync(OrchestrationContextBuiltContext context) } else if (context.Resource is AIProfile profile) { - var documentsMetadata = profile.As(); - knowledgeBaseDocuments = documentsMetadata.Documents; + knowledgeBaseDocuments = profile.TryGet(out var documentsMetadata) + ? documentsMetadata.Documents + : null; if (context.OrchestrationContext.CompletionContext?.AdditionalProperties is not null && context.OrchestrationContext.CompletionContext.AdditionalProperties.TryGetValue("Session", out var sessionObj) && diff --git a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs index 5e129292..73f2a878 100644 --- a/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Models/MemoryMetadataExtensions.cs @@ -15,9 +15,10 @@ public static class MemoryMetadataExtensions public static MemoryMetadata GetMemoryMetadata(this AIProfile profile) { ArgumentNullException.ThrowIfNull(profile); - if (profile.Has()) + + if (profile.TryGet(out var memoryMetadata)) { - return profile.As(); + return memoryMetadata; } if (TryDeserialize(profile.Settings?[LegacyAIProfileSettingsKey], out MemoryMetadata metadata) || TryDeserialize(profile.Settings?[LegacyMvcMemorySettingsKey], out metadata)) @@ -41,9 +42,10 @@ public static AIProfile AlterMemoryMetadata(this AIProfile profile, Action()) + + if (template.TryGet(out var memoryMetadata)) { - return template.As(); + return memoryMetadata; } if (TryDeserialize(GetPropertyValue(template.Properties, LegacyAIProfileSettingsKey), out MemoryMetadata metadata) || TryDeserialize(GetPropertyValue(template.Properties, LegacyMvcMemorySettingsKey), out metadata)) diff --git a/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs b/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs index 8de47f46..945a6ef7 100644 --- a/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs +++ b/src/Primitives/CrestApps.Core.AI/Orchestration/AgentToolRegistryProvider.cs @@ -44,8 +44,8 @@ public async Task> GetToolsAsync( continue; } - var agentMetadata = agent.As(); - var isAlwaysAvailable = agentMetadata?.Availability == AgentAvailability.AlwaysAvailable; + var isAlwaysAvailable = agent.TryGet(out var agentMetadata) + && agentMetadata.Availability == AgentAvailability.AlwaysAvailable; // Always-available agents are automatically included in every request. // On-demand agents are only included if explicitly selected via AgentNames. diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs index 69cfae8a..65cec042 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIDeploymentSource.cs @@ -17,17 +17,20 @@ namespace CrestApps.Core.AI.Services; public sealed class ConfigurationAIDeploymentSource : INamedSourceCatalogSource { private readonly IConfiguration _configuration; + private readonly TimeProvider _timeProvider; private readonly AIOptions _aiOptions; private readonly AIDeploymentCatalogOptions _catalogOptions; private readonly ILogger _logger; public ConfigurationAIDeploymentSource( IConfiguration configuration, + TimeProvider timeProvider, IOptions aiOptions, IOptions catalogOptions, ILogger logger) { _configuration = configuration; + _timeProvider = timeProvider; _aiOptions = aiOptions.Value; _catalogOptions = catalogOptions.Value; _logger = logger; @@ -237,6 +240,8 @@ private AIDeployment CreateConfiguredDeployment(AIDeploymentConfigurationEntry e Source = entry.ClientName, ConnectionName = entry.ConnectionName, Type = entry.Type, + IsReadOnly = true, + CreatedUtc = _timeProvider.GetUtcNow().DateTime, Properties = entry.Properties?.Count > 0 ? JsonSerializer.Deserialize>(entry.Properties.DeepClone()) : null, }; } diff --git a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs index ce4e5c8f..90667a3f 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/ConfigurationAIProviderConnectionSource.cs @@ -16,15 +16,18 @@ namespace CrestApps.Core.AI.Services; public sealed class ConfigurationAIProviderConnectionSource : INamedSourceCatalogSource { private readonly IConfiguration _configuration; + private readonly TimeProvider _timeProvider; private readonly AIProviderConnectionCatalogOptions _options; private readonly ILogger _logger; public ConfigurationAIProviderConnectionSource( IConfiguration configuration, + TimeProvider timeProvider, IOptions options, ILogger logger) { _configuration = configuration; + _timeProvider = timeProvider; _options = options.Value; _logger = logger; } @@ -159,6 +162,8 @@ private AIProviderConnection ParseConnection( Name = connectionName, DisplayText = string.IsNullOrWhiteSpace(displayText) ? connectionName : displayText, ClientName = clientName, + IsReadOnly = true, + CreatedUtc = _timeProvider.GetUtcNow().DateTime, Properties = properties.Count > 0 ? properties : null, }; } diff --git a/src/Primitives/CrestApps.Core.AI/Services/SearchIndexProfileEmbeddingMetadataAccessor.cs b/src/Primitives/CrestApps.Core.AI/Services/SearchIndexProfileEmbeddingMetadataAccessor.cs index 51b1c9a6..21fed06e 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/SearchIndexProfileEmbeddingMetadataAccessor.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/SearchIndexProfileEmbeddingMetadataAccessor.cs @@ -12,7 +12,7 @@ public static DataSourceIndexProfileMetadata GetMetadata(SearchIndexProfile inde { ArgumentNullException.ThrowIfNull(indexProfile); - var metadata = indexProfile.As(); + var metadata = indexProfile.GetOrCreate(); metadata.EmbeddingDeploymentId ??= indexProfile.EmbeddingDeploymentId; Merge(metadata, indexProfile.Get(ChatInteractionMetadataKey)); diff --git a/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs index e78eae46..1351f54d 100644 --- a/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; namespace CrestApps.Core; @@ -16,6 +17,9 @@ public static IServiceCollection AddCrestAppsCore(this IServiceCollection servic ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configure); + services.AddOptions(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + configure(new CrestAppsCoreBuilder(services)); return services; diff --git a/src/Primitives/CrestApps.Core/Services/ExtensibleEntityJsonOptionsInitializer.cs b/src/Primitives/CrestApps.Core/Services/ExtensibleEntityJsonOptionsInitializer.cs new file mode 100644 index 00000000..133dde59 --- /dev/null +++ b/src/Primitives/CrestApps.Core/Services/ExtensibleEntityJsonOptionsInitializer.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Services; + +/// +/// Pushes the DI-configured +/// into the static property +/// at application startup, before any request processing occurs. +/// +internal sealed class ExtensibleEntityJsonOptionsInitializer : IHostedService +{ + private readonly IOptions _options; + + public ExtensibleEntityJsonOptionsInitializer(IOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + ExtensibleEntityExtensions.JsonSerializerOptions = _options.Value.SerializerOptions; + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + => Task.CompletedTask; +} diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs index ee53cd06..7367a518 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Program.cs @@ -1,14 +1,22 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +// File-based crash log — Console.Error output is lost when the process dies, +// so we write to a persistent file as well. +var crashLogPath = Path.Combine(AppContext.BaseDirectory, "apphost-crash.log"); + +void WriteCrashEntry(string label, object data) { - Console.Error.WriteLine("[AppHost] Unhandled exception terminated the process."); + var message = $"[{DateTime.UtcNow:O}] {label}:{Environment.NewLine}{data}{Environment.NewLine}{Environment.NewLine}"; - if (eventArgs.ExceptionObject is Exception exception) - { - Console.Error.WriteLine(exception); - } + try { File.AppendAllText(crashLogPath, message); } catch { } + + Console.Error.Write(message); +} + +AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) => +{ + WriteCrashEntry($"Unhandled exception (IsTerminating={eventArgs.IsTerminating})", eventArgs.ExceptionObject); }; TaskScheduler.UnobservedTaskException += (_, eventArgs) => @@ -16,17 +24,17 @@ if (IsBenignAppHostException(eventArgs.Exception)) { eventArgs.SetObserved(); + return; } - Console.Error.WriteLine("[AppHost] Unobserved task exception."); - Console.Error.WriteLine(eventArgs.Exception); + WriteCrashEntry("Unobserved task exception", eventArgs.Exception); eventArgs.SetObserved(); }; AppDomain.CurrentDomain.ProcessExit += (_, _) => { - Console.Error.WriteLine("[AppHost] Process exit signaled."); + WriteCrashEntry("Process exit signaled", $"Exit code: {Environment.ExitCode}"); }; var builder = DistributedApplication.CreateBuilder(args); @@ -83,8 +91,8 @@ } catch (Exception ex) { - Console.Error.WriteLine("[AppHost] Distributed application terminated unexpectedly."); - Console.Error.WriteLine(ex); + WriteCrashEntry("Distributed application terminated unexpectedly", ex); + throw; } diff --git a/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json b/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json index 4fa5266c..65abdd7a 100644 --- a/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json +++ b/src/Startup/CrestApps.Core.Aspire.AppHost/Properties/launchSettings.json @@ -10,7 +10,10 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21194", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22004" + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22004", + "DOTNET_DbgEnableMiniDump": "1", + "DOTNET_DbgMiniDumpType": "4", + "DOTNET_CreateDumpDiagnostics": "1" } }, "http": { @@ -22,7 +25,10 @@ "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "DOTNET_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19030", - "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20028" + "DOTNET_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20028", + "DOTNET_DbgEnableMiniDump": "1", + "DOTNET_DbgMiniDumpType": "4", + "DOTNET_CreateDumpDiagnostics": "1" } } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs index d5ea7670..1d8875ec 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Controllers/A2AConnectionController.cs @@ -197,7 +197,7 @@ private void ApplyToConnection(A2AConnectionViewModel model, A2AConnection conne { connection.DisplayText = model.DisplayText?.Trim(); connection.Endpoint = model.Endpoint?.Trim(); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); var protector = _dataProtectionProvider.CreateProtector(A2AConstants.DataProtectionPurpose); var existingApiKey = metadata.ApiKey; var existingBasicPassword = metadata.BasicPassword; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/ViewModels/A2AConnectionViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/ViewModels/A2AConnectionViewModel.cs index 3208eb39..f778326c 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/ViewModels/A2AConnectionViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/ViewModels/A2AConnectionViewModel.cs @@ -63,32 +63,36 @@ public sealed class A2AConnectionViewModel public static A2AConnectionViewModel FromConnection(A2AConnection connection) { - var metadata = connection.As(); - - return new A2AConnectionViewModel + var vm = new A2AConnectionViewModel { ItemId = connection.ItemId, DisplayText = connection.DisplayText, Endpoint = connection.Endpoint, - AuthenticationType = metadata.AuthenticationType == A2AClientAuthenticationType.Anonymous && metadata.AdditionalHeaders is { Count: > 0 } + }; + + if (connection.TryGet(out var metadata)) + { + vm.AuthenticationType = metadata.AuthenticationType == A2AClientAuthenticationType.Anonymous && metadata.AdditionalHeaders is { Count: > 0 } ? A2AClientAuthenticationType.CustomHeaders - : metadata.AuthenticationType, - ApiKeyHeaderName = metadata.ApiKeyHeaderName, - ApiKeyPrefix = metadata.ApiKeyPrefix, - BasicUsername = metadata.BasicUsername, - OAuth2TokenEndpoint = metadata.OAuth2TokenEndpoint, - OAuth2ClientId = metadata.OAuth2ClientId, - OAuth2Scopes = metadata.OAuth2Scopes, - OAuth2KeyId = metadata.OAuth2KeyId, - AdditionalHeaders = metadata.AdditionalHeaders is null + : metadata.AuthenticationType; + vm.ApiKeyHeaderName = metadata.ApiKeyHeaderName; + vm.ApiKeyPrefix = metadata.ApiKeyPrefix; + vm.BasicUsername = metadata.BasicUsername; + vm.OAuth2TokenEndpoint = metadata.OAuth2TokenEndpoint; + vm.OAuth2ClientId = metadata.OAuth2ClientId; + vm.OAuth2Scopes = metadata.OAuth2Scopes; + vm.OAuth2KeyId = metadata.OAuth2KeyId; + vm.AdditionalHeaders = metadata.AdditionalHeaders is null ? null - : JsonSerializer.Serialize(metadata.AdditionalHeaders, _serializerOptions), - HasApiKey = !string.IsNullOrEmpty(metadata.ApiKey), - HasBasicPassword = !string.IsNullOrEmpty(metadata.BasicPassword), - HasOAuth2ClientSecret = !string.IsNullOrEmpty(metadata.OAuth2ClientSecret), - HasOAuth2PrivateKey = !string.IsNullOrEmpty(metadata.OAuth2PrivateKey), - HasOAuth2ClientCertificate = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificate), - HasOAuth2ClientCertificatePassword = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificatePassword), - }; + : JsonSerializer.Serialize(metadata.AdditionalHeaders, _serializerOptions); + vm.HasApiKey = !string.IsNullOrEmpty(metadata.ApiKey); + vm.HasBasicPassword = !string.IsNullOrEmpty(metadata.BasicPassword); + vm.HasOAuth2ClientSecret = !string.IsNullOrEmpty(metadata.OAuth2ClientSecret); + vm.HasOAuth2PrivateKey = !string.IsNullOrEmpty(metadata.OAuth2PrivateKey); + vm.HasOAuth2ClientCertificate = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificate); + vm.HasOAuth2ClientCertificatePassword = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificatePassword); + } + + return vm; } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Views/A2AConnection/Index.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Views/A2AConnection/Index.cshtml index 0ad8eb3a..14131288 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Views/A2AConnection/Index.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/A2A/Views/A2AConnection/Index.cshtml @@ -34,11 +34,11 @@ else @foreach (var connection in Model) { - var metadata = connection.As(); + connection.TryGet(out var metadata); @connection.DisplayText @connection.Endpoint - @metadata.AuthenticationType + @(metadata?.AuthenticationType) Edit diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index 4a87e56a..d4e15f58 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -318,8 +318,7 @@ private async Task PopulateAttachedDocumentsAsync(AIProfileViewModel model, stri private static void ApplyTemplateToProfile(AIProfile profile, AIProfileTemplate template) { - var metadata = template.As(); - if (metadata == null) + if (!template.TryGet(out var metadata)) { return; } @@ -369,7 +368,7 @@ private static void ApplyTemplateToProfile(AIProfile profile, AIProfileTemplate profile.Put(new AgentMetadata { Availability = metadata.AgentAvailability.Value, }); } - var profileMetadata = profile.As(); + var profileMetadata = profile.GetOrCreate(); if (!string.IsNullOrWhiteSpace(metadata.SystemMessage)) { profileMetadata.SystemMessage = metadata.SystemMessage; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs index c1ae39ca..f4e988ff 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileDocumentService.cs @@ -87,7 +87,7 @@ public async Task UploadDocumentsAsync(AIProfile profile, IReadOnlyCollection(); + var documentsMetadata = profile.GetOrCreate(); documentsMetadata.Documents ??= []; documentsMetadata.Documents.Add(result.DocumentInfo); profile.Put(documentsMetadata); @@ -104,7 +104,7 @@ public async Task RemoveDocumentsAsync(AIProfile profile, IReadOnlyCollection(); + var documentsMetadata = profile.GetOrCreate(); if (documentsMetadata?.Documents == null || documentsMetadata.Documents.Count == 0) { @@ -161,8 +161,9 @@ public Task RemoveAllDocumentsAsync(AIProfile profile, CancellationToken cancell { ArgumentNullException.ThrowIfNull(profile); - var documentsMetadata = profile.As(); - var documentIds = (documentsMetadata?.Documents ?? []) + var documentIds = (profile.TryGet(out var documentsMetadata) + ? documentsMetadata.Documents ?? [] + : []) .Select(document => document.DocumentId) .Where(documentId => !string.IsNullOrWhiteSpace(documentId)) .Distinct(StringComparer.OrdinalIgnoreCase) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs index e94e914d..f0356d27 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Services/AIProfileTemplateDocumentService.cs @@ -87,7 +87,7 @@ public async Task UploadDocumentsAsync(AIProfileTemplate template, IReadOnlyColl await _documentIndexingService.IndexAsync(result.Document, result.Chunks, cancellationToken); - var documentsMetadata = template.As(); + var documentsMetadata = template.GetOrCreate(); documentsMetadata.Documents ??= []; documentsMetadata.Documents.Add(result.DocumentInfo); template.Put(documentsMetadata); @@ -104,7 +104,7 @@ public async Task RemoveDocumentsAsync(AIProfileTemplate template, IReadOnlyColl ArgumentNullException.ThrowIfNull(template); ArgumentNullException.ThrowIfNull(documentIds); - var documentsMetadata = template.As(); + var documentsMetadata = template.GetOrCreate(); if (documentsMetadata?.Documents == null || documentsMetadata.Documents.Count == 0) { @@ -162,14 +162,16 @@ public async Task CloneDocumentsToProfileAsync(AIProfileTemplate template, AIPro ArgumentNullException.ThrowIfNull(template); ArgumentNullException.ThrowIfNull(profile); - var templateDocuments = template.As()?.Documents; + var templateDocuments = template.TryGet(out var templateDocMetadata) + ? templateDocMetadata.Documents + : null; if (templateDocuments == null || templateDocuments.Count == 0) { return; } - var profileDocuments = profile.As(); + var profileDocuments = profile.GetOrCreate(); profileDocuments.Documents ??= []; foreach (var docInfo in templateDocuments) @@ -279,9 +281,9 @@ private async Task ResolveEmbeddingDeploymentAsync(AIProfileTempla private async Task ResolveTemplateDeploymentAsync(AIProfileTemplate template) { - var metadata = template.As(); + template.TryGet(out var metadata); - if (!string.IsNullOrWhiteSpace(metadata?.ChatDeploymentName)) + if (metadata is not null && !string.IsNullOrWhiteSpace(metadata.ChatDeploymentName)) { var chatDeployment = await _deploymentManager.ResolveOrDefaultAsync( AIDeploymentType.Chat, diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index 3b598246..3da0cde4 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -166,20 +166,11 @@ public sealed class AIProfileViewModel public static AIProfileViewModel FromProfile(AIProfile profile) { - var metadata = profile.As(); var settings = profile.GetSettings(); - var toolMetadata = profile.As(); - var docMetadata = profile.As(); - var sessionDocMetadata = profile.As(); var dataExtractionSettings = profile.GetSettings(); - var analyticsMetadata = profile.As(); var postSessionSettings = profile.GetSettings(); var memoryMetadata = profile.GetMemoryMetadata(); profile.TryGetSettings(out var chatModeSettings); - var a2aMetadata = profile.As(); - var mcpMetadata = profile.As(); - var promptMetadata = profile.As(); - var dataSourceRagMetadata = profile.As(); var vm = new AIProfileViewModel { @@ -197,55 +188,13 @@ public static AIProfileViewModel FromProfile(AIProfile profile) Description = profile.Description, TitleType = profile.TitleType, - AddInitialPrompt = !string.IsNullOrEmpty(metadata.InitialPrompt), - InitialPrompt = metadata.InitialPrompt, ChatMode = chatModeSettings?.ChatMode ?? ChatMode.TextInput, VoiceName = chatModeSettings?.VoiceName, - SystemMessage = metadata.SystemMessage, - Temperature = metadata.Temperature, - TopP = metadata.TopP, - FrequencyPenalty = metadata.FrequencyPenalty, - PresencePenalty = metadata.PresencePenalty, - MaxTokens = metadata.MaxTokens, - PastMessagesCount = metadata.PastMessagesCount, - UseCaching = metadata.UseCaching, - LockSystemMessage = settings.LockSystemMessage, IsListable = settings.IsListable, IsRemovable = settings.IsRemovable, - SelectedToolNames = toolMetadata?.Names ?? [], - SelectedAgentNames = profile.As().Names ?? [], - DataSourceId = profile.As().DataSourceId, - DataSourceStrictness = dataSourceRagMetadata.Strictness, - DataSourceTopNDocuments = dataSourceRagMetadata.TopNDocuments, - DataSourceIsInScope = dataSourceRagMetadata.IsInScope, - DataSourceFilter = dataSourceRagMetadata.Filter, - SelectedA2AConnectionIds = a2aMetadata?.ConnectionIds ?? [], - SelectedMcpConnectionIds = mcpMetadata?.ConnectionIds ?? [], - - PromptTemplates = (promptMetadata.Templates ?? []) - .Where(t => !string.IsNullOrWhiteSpace(t.TemplateId)) - .Select(t => new PromptTemplateSelectionItem - { - TemplateId = t.TemplateId, - PromptParameters = t.Parameters is { Count: > 0 } - ? System.Text.Json.JsonSerializer.Serialize(t.Parameters) - : null, - }) - .ToList(), - - DocumentTopN = docMetadata?.DocumentTopN, - AllowSessionDocuments = sessionDocMetadata?.AllowSessionDocuments ?? false, - AttachedDocuments = (docMetadata?.Documents ?? []).Select(d => new DocumentItem - { - DocumentId = d.DocumentId, - FileName = d.FileName, - ContentType = d.ContentType, - FileSize = d.FileSize, - }).ToList(), - EnableDataExtraction = dataExtractionSettings.EnableDataExtraction, ExtractionCheckInterval = dataExtractionSettings.ExtractionCheckInterval, SessionInactivityTimeoutInMinutes = dataExtractionSettings.SessionInactivityTimeoutInMinutes, @@ -259,19 +208,6 @@ public static AIProfileViewModel FromProfile(AIProfile profile) }) .ToList(), - EnableSessionMetrics = analyticsMetadata.EnableSessionMetrics, - EnableAIResolutionDetection = analyticsMetadata.EnableAIResolutionDetection, - EnableConversionMetrics = analyticsMetadata.EnableConversionMetrics, - ConversionGoals = analyticsMetadata.ConversionGoals - .Select(g => new ConversionGoalItem - { - Name = g.Name, - Description = g.Description, - MinScore = g.MinScore, - MaxScore = g.MaxScore, - }) - .ToList(), - EnablePostSessionProcessing = postSessionSettings.EnablePostSessionProcessing, PostSessionTasks = postSessionSettings.PostSessionTasks.Select(t => new PostSessionTaskItem { @@ -289,6 +225,100 @@ public static AIProfileViewModel FromProfile(AIProfile profile) EnableUserMemory = memoryMetadata.EnableUserMemory ?? false, }; + if (profile.TryGet(out var metadata)) + { + vm.AddInitialPrompt = !string.IsNullOrEmpty(metadata.InitialPrompt); + vm.InitialPrompt = metadata.InitialPrompt; + vm.SystemMessage = metadata.SystemMessage; + vm.Temperature = metadata.Temperature; + vm.TopP = metadata.TopP; + vm.FrequencyPenalty = metadata.FrequencyPenalty; + vm.PresencePenalty = metadata.PresencePenalty; + vm.MaxTokens = metadata.MaxTokens; + vm.PastMessagesCount = metadata.PastMessagesCount; + vm.UseCaching = metadata.UseCaching; + } + + if (profile.TryGet(out var toolMetadata)) + { + vm.SelectedToolNames = toolMetadata.Names ?? []; + } + + if (profile.TryGet(out var agentMetadata)) + { + vm.SelectedAgentNames = agentMetadata.Names ?? []; + } + + if (profile.TryGet(out var dataSourceMetadata)) + { + vm.DataSourceId = dataSourceMetadata.DataSourceId; + } + + if (profile.TryGet(out var dataSourceRagMetadata)) + { + vm.DataSourceStrictness = dataSourceRagMetadata.Strictness; + vm.DataSourceTopNDocuments = dataSourceRagMetadata.TopNDocuments; + vm.DataSourceIsInScope = dataSourceRagMetadata.IsInScope; + vm.DataSourceFilter = dataSourceRagMetadata.Filter; + } + + if (profile.TryGet(out var a2aMetadata)) + { + vm.SelectedA2AConnectionIds = a2aMetadata.ConnectionIds ?? []; + } + + if (profile.TryGet(out var mcpMetadata)) + { + vm.SelectedMcpConnectionIds = mcpMetadata.ConnectionIds ?? []; + } + + if (profile.TryGet(out var promptMetadata)) + { + vm.PromptTemplates = (promptMetadata.Templates ?? []) + .Where(t => !string.IsNullOrWhiteSpace(t.TemplateId)) + .Select(t => new PromptTemplateSelectionItem + { + TemplateId = t.TemplateId, + PromptParameters = t.Parameters is { Count: > 0 } + ? System.Text.Json.JsonSerializer.Serialize(t.Parameters) + : null, + }) + .ToList(); + } + + if (profile.TryGet(out var docMetadata)) + { + vm.DocumentTopN = docMetadata.DocumentTopN; + vm.AttachedDocuments = (docMetadata.Documents ?? []).Select(d => new DocumentItem + { + DocumentId = d.DocumentId, + FileName = d.FileName, + ContentType = d.ContentType, + FileSize = d.FileSize, + }).ToList(); + } + + if (profile.TryGet(out var sessionDocMetadata)) + { + vm.AllowSessionDocuments = sessionDocMetadata.AllowSessionDocuments; + } + + if (profile.TryGet(out var analyticsMetadata)) + { + vm.EnableSessionMetrics = analyticsMetadata.EnableSessionMetrics; + vm.EnableAIResolutionDetection = analyticsMetadata.EnableAIResolutionDetection; + vm.EnableConversionMetrics = analyticsMetadata.EnableConversionMetrics; + vm.ConversionGoals = analyticsMetadata.ConversionGoals + .Select(g => new ConversionGoalItem + { + Name = g.Name, + Description = g.Description, + MinScore = g.MinScore, + MaxScore = g.MaxScore, + }) + .ToList(); + } + // Load Copilot metadata if present if (profile.TryGet(out var copilotMeta)) { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs index 601e0daa..dd1c0a8a 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs @@ -161,18 +161,14 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) if (template.Source == AITemplateSources.SystemPrompt) { - var metadata = template.As(); - - if (metadata != null) + if (template.TryGet(out var sysMetadata)) { - model.SystemMessage = metadata.SystemMessage; + model.SystemMessage = sysMetadata.SystemMessage; } } else if (template.Source == AITemplateSources.Profile) { - var metadata = template.As(); - - if (metadata != null) + if (template.TryGet(out var metadata)) { model.ProfileType = metadata.ProfileType; model.SystemMessage = metadata.SystemMessage; @@ -194,61 +190,56 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) model.SelectedAgentNames = metadata.AgentNames ?? []; } - var mcpMetadata = template.As(); - - if (mcpMetadata != null) + if (template.TryGet(out var mcpMetadata)) { model.SelectedMcpConnectionIds = mcpMetadata.ConnectionIds ?? []; } - var aiMetadata = template.As(); - - if (aiMetadata != null) + if (template.TryGet(out var aiMetadata)) { model.UseCaching = aiMetadata.UseCaching; model.AddInitialPrompt = !string.IsNullOrEmpty(aiMetadata.InitialPrompt); model.InitialPrompt = aiMetadata.InitialPrompt; } - var promptMetadata = template.As(); - - model.PromptTemplates = (promptMetadata?.Templates ?? []) - .Where(t => !string.IsNullOrWhiteSpace(t.TemplateId)) - .Select(t => new PromptTemplateSelectionItem - { - TemplateId = t.TemplateId, - PromptParameters = t.Parameters is { Count: > 0 } - ? JsonSerializer.Serialize(t.Parameters) - : null, - }) - .ToList(); - - var dataSourceMetadata = template.As(); - var ragMetadata = template.As(); + if (template.TryGet(out var promptMetadata)) + { + model.PromptTemplates = (promptMetadata.Templates ?? []) + .Where(t => !string.IsNullOrWhiteSpace(t.TemplateId)) + .Select(t => new PromptTemplateSelectionItem + { + TemplateId = t.TemplateId, + PromptParameters = t.Parameters is { Count: > 0 } + ? JsonSerializer.Serialize(t.Parameters) + : null, + }) + .ToList(); + } - model.DataSourceId = dataSourceMetadata?.DataSourceId; - model.DataSourceStrictness = ragMetadata?.Strictness; - model.DataSourceTopNDocuments = ragMetadata?.TopNDocuments; - model.DataSourceIsInScope = ragMetadata?.IsInScope ?? false; - model.DataSourceFilter = ragMetadata?.Filter; + if (template.TryGet(out var dataSourceMetadata)) + { + model.DataSourceId = dataSourceMetadata.DataSourceId; + } - var sessionDocMetadata = template.As(); + if (template.TryGet(out var ragMetadata)) + { + model.DataSourceStrictness = ragMetadata.Strictness; + model.DataSourceTopNDocuments = ragMetadata.TopNDocuments; + model.DataSourceIsInScope = ragMetadata.IsInScope; + model.DataSourceFilter = ragMetadata.Filter; + } - if (sessionDocMetadata != null) + if (template.TryGet(out var sessionDocMetadata)) { model.AllowSessionDocuments = sessionDocMetadata.AllowSessionDocuments; } - var docMetadata = template.As(); - - if (docMetadata != null) + if (template.TryGet(out var docMetadata)) { model.DocumentTopN = docMetadata.DocumentTopN; } - var dataExtractionSettings = template.As(); - - if (dataExtractionSettings != null) + if (template.TryGet(out var dataExtractionSettings)) { model.EnableDataExtraction = dataExtractionSettings.EnableDataExtraction; model.ExtractionCheckInterval = dataExtractionSettings.ExtractionCheckInterval; @@ -264,9 +255,7 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) .ToList(); } - var analyticsMetadata = template.As(); - - if (analyticsMetadata != null) + if (template.TryGet(out var analyticsMetadata)) { model.EnableSessionMetrics = analyticsMetadata.EnableSessionMetrics; model.EnableAIResolutionDetection = analyticsMetadata.EnableAIResolutionDetection; @@ -282,9 +271,7 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) .ToList(); } - var postSessionSettings = template.As(); - - if (postSessionSettings != null) + if (template.TryGet(out var postSessionSettings)) { model.EnablePostSessionProcessing = postSessionSettings.EnablePostSessionProcessing; model.PostSessionTasks = postSessionSettings.PostSessionTasks @@ -305,9 +292,7 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) model.EnableUserMemory = template.GetMemoryMetadata().EnableUserMemory ?? false; - var profileSettings = template.As(); - - if (profileSettings != null) + if (template.TryGet(out var profileSettings)) { model.IsRemovable = profileSettings.IsRemovable; model.LockSystemMessage = profileSettings.LockSystemMessage; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs index 3d634455..e778b804 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Handlers/AnalyticsChatSessionHandler.cs @@ -21,9 +21,7 @@ public AnalyticsChatSessionHandler( public override async Task MessageCompletedAsync(ChatMessageCompletedContext context) { - var analyticsMetadata = context.Profile.As(); - - if (!analyticsMetadata.EnableSessionMetrics) + if (!context.Profile.TryGet(out var analyticsMetadata) || !analyticsMetadata.EnableSessionMetrics) { return; } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml index 0746d1f8..12cb5849 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml @@ -23,9 +23,8 @@ : "What do you want to know?"; var supportedExtensionsAccept = ChatDocumentOptions.Value.GetAllowedFileExtensionsAcceptValue(); var supportedExtensionsDisplay = ChatDocumentOptions.Value.GetAllowedFileExtensionsDisplayValue(); - var sessionDocumentsMetadata = Model.As(); - var allowSessionDocuments = sessionDocumentsMetadata?.AllowSessionDocuments ?? false; - var metricsEnabled = Model.As()?.EnableSessionMetrics ?? false; + var allowSessionDocuments = Model.TryGet(out var sessionDocumentsMetadata) && sessionDocumentsMetadata.AllowSessionDocuments; + var metricsEnabled = Model.TryGet(out var analyticsMetadataVal) && analyticsMetadataVal.EnableSessionMetrics; var existingDocuments = session?.Documents ?? []; var defaultDeploymentSettings = SiteSettings.Get(); ChatModeProfileSettings chatModeSettings = null; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/Shared/_ChatWidget.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/Shared/_ChatWidget.cshtml index c55b4e94..d71af72f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/Shared/_ChatWidget.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/Shared/_ChatWidget.cshtml @@ -41,8 +41,8 @@ name = selectedProfile.Name, displayText = selectedProfile.DisplayText ?? selectedProfile.Name, welcomeMessage = selectedProfile.WelcomeMessage, - enableSessionMetrics = selectedProfile.As()?.EnableSessionMetrics ?? false, - allowSessionDocuments = selectedProfile.As()?.AllowSessionDocuments ?? false, + enableSessionMetrics = selectedProfile.TryGet(out var analyticsMetadata) && analyticsMetadata.EnableSessionMetrics, + allowSessionDocuments = selectedProfile.TryGet(out var sessionDocMetadata) && sessionDocMetadata.AllowSessionDocuments, }; var widgetStyle = string.IsNullOrWhiteSpace(adminWidgetSettings.PrimaryColor) ? null diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs index 7348b36c..c76775b1 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/ChatInteractions/Controllers/ChatInteractionController.cs @@ -194,9 +194,9 @@ public async Task Chat(string id) var chatInteractionSettings = _siteSettings.Get(); var deploymentDefaults = _siteSettings.Get(); - var dataSourceMetadata = interaction.As(); + interaction.TryGet(out var dataSourceMetadata); interaction.TryGet(out var ragMetadata); - var promptMetadata = interaction.As(); + interaction.TryGet(out var promptMetadata); var chatMode = chatInteractionSettings.ChatMode; var hasSpeechToText = !string.IsNullOrWhiteSpace(deploymentDefaults.DefaultSpeechToTextDeploymentName); @@ -223,7 +223,7 @@ public async Task Chat(string id) MaxTokens = interaction.MaxTokens, PastMessagesCount = interaction.PastMessagesCount, Documents = interaction.Documents ?? [], - DataSourceId = string.IsNullOrWhiteSpace(dataSourceMetadata.DataSourceId) ? null : dataSourceMetadata.DataSourceId, + DataSourceId = dataSourceMetadata is not null && !string.IsNullOrWhiteSpace(dataSourceMetadata.DataSourceId) ? dataSourceMetadata.DataSourceId : null, DataSourceStrictness = ragMetadata?.Strictness, DataSourceTopNDocuments = ragMetadata?.TopNDocuments, DataSourceIsInScope = ragMetadata?.IsInScope ?? false, @@ -232,7 +232,7 @@ public async Task Chat(string id) SelectedMcpConnectionIds = interaction.McpConnectionIds?.ToArray() ?? [], SelectedToolNames = interaction.ToolNames?.ToArray() ?? [], SelectedAgentNames = interaction.AgentNames?.ToArray() ?? [], - PromptTemplates = (promptMetadata.Templates ?? []) + PromptTemplates = (promptMetadata?.Templates ?? []) .Where(template => !string.IsNullOrWhiteSpace(template.TemplateId)) .Select(template => new PromptTemplateSelectionItem { diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs index e4fe802f..c3a14f22 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Indexing/Controllers/AIDocumentController.cs @@ -98,7 +98,7 @@ public async Task Upload(string profileId, IFormFile file) await _documentIndexingService.IndexAsync(result.Document, result.Chunks); // Update the profile's document metadata. - var documentsMetadata = profile.As(); + var documentsMetadata = profile.GetOrCreate(); documentsMetadata.Documents ??= []; documentsMetadata.Documents.Add(result.DocumentInfo); profile.Put(documentsMetadata); @@ -136,7 +136,7 @@ public async Task Delete(string profileId, string documentId) } // Remove from profile metadata. - var documentsMetadata = profile.As(); + var documentsMetadata = profile.GetOrCreate(); documentsMetadata.Documents ??= []; documentsMetadata.Documents = documentsMetadata.Documents.Where(d => d.DocumentId != documentId).ToList(); profile.Put(documentsMetadata); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs index 0797765a..6620e86b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpConnectionController.cs @@ -298,7 +298,7 @@ private void Apply(McpConnectionViewModel model, McpConnection connection) private void ApplySseTransport(McpConnectionViewModel model, McpConnection connection) { var authenticationType = ResolveAuthenticationType(model.AuthenticationType); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); var protector = _dataProtectionProvider.CreateProtector(McpConstants.DataProtectionPurpose); var existingApiKey = metadata.ApiKey; var existingBasicPassword = metadata.BasicPassword; @@ -439,31 +439,35 @@ private static McpConnectionViewModel ToViewModel(McpConnection connection) }; if (connection.Source == McpConstants.TransportTypes.Sse) { - var metadata = connection.As(); - model.Endpoint = metadata.Endpoint?.ToString(); - model.AuthenticationType = metadata.AuthenticationType; - model.ApiKeyHeaderName = metadata.ApiKeyHeaderName; - model.ApiKeyPrefix = metadata.ApiKeyPrefix; - model.HasApiKey = !string.IsNullOrEmpty(metadata.ApiKey); - model.BasicUsername = metadata.BasicUsername; - model.HasBasicPassword = !string.IsNullOrEmpty(metadata.BasicPassword); - model.OAuth2TokenEndpoint = metadata.OAuth2TokenEndpoint; - model.OAuth2ClientId = metadata.OAuth2ClientId; - model.OAuth2Scopes = metadata.OAuth2Scopes; - model.HasOAuth2ClientSecret = !string.IsNullOrEmpty(metadata.OAuth2ClientSecret); - model.OAuth2KeyId = metadata.OAuth2KeyId; - model.HasOAuth2PrivateKey = !string.IsNullOrEmpty(metadata.OAuth2PrivateKey); - model.HasOAuth2ClientCertificate = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificate); - model.HasOAuth2ClientCertificatePassword = !string.IsNullOrEmpty(metadata.OAuth2ClientCertificatePassword); - model.AdditionalHeaders = metadata.AdditionalHeaders is not null ? JsonSerializer.Serialize(metadata.AdditionalHeaders, _indentedJsonOptions) : "{}"; + if (connection.TryGet(out var sseMetadata)) + { + model.Endpoint = sseMetadata.Endpoint?.ToString(); + model.AuthenticationType = sseMetadata.AuthenticationType; + model.ApiKeyHeaderName = sseMetadata.ApiKeyHeaderName; + model.ApiKeyPrefix = sseMetadata.ApiKeyPrefix; + model.HasApiKey = !string.IsNullOrEmpty(sseMetadata.ApiKey); + model.BasicUsername = sseMetadata.BasicUsername; + model.HasBasicPassword = !string.IsNullOrEmpty(sseMetadata.BasicPassword); + model.OAuth2TokenEndpoint = sseMetadata.OAuth2TokenEndpoint; + model.OAuth2ClientId = sseMetadata.OAuth2ClientId; + model.OAuth2Scopes = sseMetadata.OAuth2Scopes; + model.HasOAuth2ClientSecret = !string.IsNullOrEmpty(sseMetadata.OAuth2ClientSecret); + model.OAuth2KeyId = sseMetadata.OAuth2KeyId; + model.HasOAuth2PrivateKey = !string.IsNullOrEmpty(sseMetadata.OAuth2PrivateKey); + model.HasOAuth2ClientCertificate = !string.IsNullOrEmpty(sseMetadata.OAuth2ClientCertificate); + model.HasOAuth2ClientCertificatePassword = !string.IsNullOrEmpty(sseMetadata.OAuth2ClientCertificatePassword); + model.AdditionalHeaders = sseMetadata.AdditionalHeaders is not null ? JsonSerializer.Serialize(sseMetadata.AdditionalHeaders, _indentedJsonOptions) : "{}"; + } } else { - var metadata = connection.As(); - model.Command = metadata.Command; - model.Arguments = metadata.Arguments is { Length: > 0 } ? JsonSerializer.Serialize(metadata.Arguments, _indentedJsonOptions) : "[]"; - model.WorkingDirectory = metadata.WorkingDirectory; - model.EnvironmentVariables = metadata.EnvironmentVariables is { Count: > 0 } ? JsonSerializer.Serialize(metadata.EnvironmentVariables, _indentedJsonOptions) : "{}"; + if (connection.TryGet(out var stdioMetadata)) + { + model.Command = stdioMetadata.Command; + model.Arguments = stdioMetadata.Arguments is { Length: > 0 } ? JsonSerializer.Serialize(stdioMetadata.Arguments, _indentedJsonOptions) : "[]"; + model.WorkingDirectory = stdioMetadata.WorkingDirectory; + model.EnvironmentVariables = stdioMetadata.EnvironmentVariables is { Count: > 0 } ? JsonSerializer.Serialize(stdioMetadata.EnvironmentVariables, _indentedJsonOptions) : "{}"; + } } return model; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs index 9c20defd..5f8f0b83 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/Mcp/Controllers/McpResourceController.cs @@ -175,7 +175,7 @@ private void Apply(McpResourceViewModel model, McpResource resource) if (model.Source == FtpResourceConstants.Type) { var protector = _dataProtectionProvider.CreateProtector(FtpResourceConstants.DataProtectionPurpose); - var metadata = resource.As(); + var metadata = resource.GetOrCreate(); var existingPassword = metadata.Password; metadata.Host = model.Host?.Trim(); metadata.Port = model.Port; @@ -192,7 +192,7 @@ private void Apply(McpResourceViewModel model, McpResource resource) else if (model.Source == SftpResourceConstants.Type) { var protector = _dataProtectionProvider.CreateProtector(SftpResourceConstants.DataProtectionPurpose); - var metadata = resource.As(); + var metadata = resource.GetOrCreate(); var existingPassword = metadata.Password; var existingPrivateKey = metadata.PrivateKey; var existingPassphrase = metadata.Passphrase; @@ -233,34 +233,38 @@ private static McpResourceViewModel ToViewModel(McpResource resource) }; if (resource.Source == FtpResourceConstants.Type) { - var metadata = resource.As(); - model.Host = metadata.Host; - model.Port = metadata.Port; - model.Username = metadata.Username; - model.HasPassword = !string.IsNullOrEmpty(metadata.Password); - model.EncryptionMode = metadata.EncryptionMode; - model.DataConnectionType = metadata.DataConnectionType; - model.ValidateAnyCertificate = metadata.ValidateAnyCertificate; - model.ConnectTimeout = metadata.ConnectTimeout; - model.ReadTimeout = metadata.ReadTimeout; - model.RetryAttempts = metadata.RetryAttempts; + if (resource.TryGet(out var ftpMetadata)) + { + model.Host = ftpMetadata.Host; + model.Port = ftpMetadata.Port; + model.Username = ftpMetadata.Username; + model.HasPassword = !string.IsNullOrEmpty(ftpMetadata.Password); + model.EncryptionMode = ftpMetadata.EncryptionMode; + model.DataConnectionType = ftpMetadata.DataConnectionType; + model.ValidateAnyCertificate = ftpMetadata.ValidateAnyCertificate; + model.ConnectTimeout = ftpMetadata.ConnectTimeout; + model.ReadTimeout = ftpMetadata.ReadTimeout; + model.RetryAttempts = ftpMetadata.RetryAttempts; + } } else if (resource.Source == SftpResourceConstants.Type) { - var metadata = resource.As(); - model.Host = metadata.Host; - model.Port = metadata.Port; - model.Username = metadata.Username; - model.HasPassword = !string.IsNullOrEmpty(metadata.Password); - model.HasPrivateKey = !string.IsNullOrEmpty(metadata.PrivateKey); - model.HasPassphrase = !string.IsNullOrEmpty(metadata.Passphrase); - model.ProxyType = metadata.ProxyType; - model.ProxyHost = metadata.ProxyHost; - model.ProxyPort = metadata.ProxyPort; - model.ProxyUsername = metadata.ProxyUsername; - model.HasProxyPassword = !string.IsNullOrEmpty(metadata.ProxyPassword); - model.ConnectionTimeout = metadata.ConnectionTimeout; - model.KeepAliveInterval = metadata.KeepAliveInterval; + if (resource.TryGet(out var sftpMetadata)) + { + model.Host = sftpMetadata.Host; + model.Port = sftpMetadata.Port; + model.Username = sftpMetadata.Username; + model.HasPassword = !string.IsNullOrEmpty(sftpMetadata.Password); + model.HasPrivateKey = !string.IsNullOrEmpty(sftpMetadata.PrivateKey); + model.HasPassphrase = !string.IsNullOrEmpty(sftpMetadata.Passphrase); + model.ProxyType = sftpMetadata.ProxyType; + model.ProxyHost = sftpMetadata.ProxyHost; + model.ProxyPort = sftpMetadata.ProxyPort; + model.ProxyUsername = sftpMetadata.ProxyUsername; + model.HasProxyPassword = !string.IsNullOrEmpty(sftpMetadata.ProxyPassword); + model.ConnectionTimeout = sftpMetadata.ConnectionTimeout; + model.KeepAliveInterval = sftpMetadata.KeepAliveInterval; + } } return model; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs index 1ffabb35..1f24dca6 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Program.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Program.cs @@ -23,7 +23,6 @@ using CrestApps.Core.Elasticsearch; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Mvc.Web.Areas.Admin.Handlers; -using CrestApps.Core.Mvc.Web.Areas.AI.Services; using CrestApps.Core.Mvc.Web.Areas.AIChat.BackgroundServices; using CrestApps.Core.Mvc.Web.Areas.AIChat.Endpoints; using CrestApps.Core.Mvc.Web.Areas.AIChat.Hubs; @@ -49,23 +48,74 @@ // does and why it is needed. // // Sections: -// 1. Logging -// 2. Application Configuration (App_Data appsettings override) -// 3. ASP.NET Core MVC setup -// 4. Authentication & Authorization -// 5. CrestApps foundation + AI services -// 6. AI Providers (OpenAI, Azure OpenAI, Ollama, Azure AI Inference) -// 7. Elasticsearch services -// 8. Azure AI Search services -// 9. MCP — Model Context Protocol (client + server) -// 10. Custom AI Tools -// 11. Data Store (YesSql / SQLite — replaceable with any ORM) -// 12. Background Tasks -// 13. Middleware Pipeline +// 1. Crash Diagnostics & Host Resilience +// 2. Logging +// 3. Application Configuration (App_Data appsettings override) +// 4. ASP.NET Core MVC setup +// 5. Authentication & Authorization +// 6. CrestApps foundation + AI services +// 7. AI Providers (OpenAI, Azure OpenAI, Ollama, Azure AI Inference) +// 8. Elasticsearch services +// 9. Azure AI Search services +// 10. MCP — Model Context Protocol (client + server) +// 11. Custom AI Tools +// 12. Data Store (YesSql / SQLite — replaceable with any ORM) +// 13. Background Tasks +// 14. Middleware Pipeline // ============================================================================= var builder = WebApplication.CreateBuilder(args); + +// Early startup marker — writes immediately to confirm the process launched. +var crashLogDir = Path.Combine(builder.Environment.ContentRootPath, "App_Data", "logs"); +Directory.CreateDirectory(crashLogDir); +File.WriteAllText( + Path.Combine(crashLogDir, "startup-marker.txt"), + $"Process started at {DateTime.UtcNow:O}, PID={Environment.ProcessId}{Environment.NewLine}"); + +AppDomain.CurrentDomain.UnhandledException += (_, e) => +{ + var message = $"[{DateTime.UtcNow:O}] Unhandled exception (IsTerminating={e.IsTerminating}):{Environment.NewLine}{e.ExceptionObject}{Environment.NewLine}"; + + try + { + File.AppendAllText(Path.Combine(crashLogDir, "crash.log"), message); + } + catch + { + // Best-effort — the process is already dying. + } + + Console.Error.Write(message); +}; + +TaskScheduler.UnobservedTaskException += (_, e) => +{ + var message = $"[{DateTime.UtcNow:O}] Unobserved task exception:{Environment.NewLine}{e.Exception}{Environment.NewLine}"; + + try + { + File.AppendAllText(Path.Combine(crashLogDir, "crash.log"), message); + } + catch + { + // Best-effort. + } + + Console.Error.Write(message); + e.SetObserved(); +}; + +// Prevent background/hosted-service exceptions from tearing down the host. +// The default BackgroundServiceExceptionBehavior.StopHost silently kills the +// process when any IHostedService.ExecuteAsync throws — even if the exception +// is transient. With Ignore, the exception is still logged but the host +// continues running. +builder.Services.Configure(options => +{ + options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore; +}); // ============================================================================= -// 1. LOGGING +// 2. LOGGING // ============================================================================= // NLog writes daily log files to App_Data/logs/. Replace with your preferred // logging provider (Serilog, Application Insights, etc.) if desired. @@ -76,7 +126,7 @@ var appDataPath = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); Directory.CreateDirectory(appDataPath); // ============================================================================= -// 2. APPLICATION CONFIGURATION +// 3. APPLICATION CONFIGURATION // ============================================================================= // Two-layer App_Data configuration: // @@ -108,7 +158,7 @@ builder.Services.AddSingleton, SiteSettingsConfigureAIDataSourceOptions>(); builder.Services.AddSingleton, SiteSettingsConfigureChatInteractionMemoryOptions>(); // ============================================================================= -// 3. ASP.NET CORE MVC SETUP +// 4. ASP.NET CORE MVC SETUP // ============================================================================= // Start with the standard ASP.NET Core building blocks before adding CrestApps- // specific features. This keeps the host framework registrations easy to find. @@ -118,7 +168,7 @@ .AddCrestAppsStoreCommitterFilter(); builder.Services.AddHttpContextAccessor(); // ============================================================================= -// 4. AUTHENTICATION & AUTHORIZATION +// 5. AUTHENTICATION & AUTHORIZATION // ============================================================================= // Cookie-based authentication with a simple "Admin" policy. Replace with your // preferred auth scheme (JWT, OpenID Connect, etc.). @@ -131,7 +181,7 @@ builder.Services.AddAuthorizationBuilder() .AddPolicy("Admin", policy => policy.RequireRole("Administrator")); // ============================================================================= -// 5. CRESTAPPS FOUNDATION + AI SERVICES +// 6. CRESTAPPS FOUNDATION + AI SERVICES // ============================================================================= // These are the shared CrestApps service registrations that sit on top of the // normal ASP.NET Core host. Keep them together so consumers can clearly see the @@ -142,7 +192,7 @@ .AddCrestAppsCore(crestApps => crestApps .AddAISuite(ai => ai .AddYesSqlStores() - .ConfigureProviderOptions(builder.Configuration.GetSection("CrestApps:AI:Providers")) + // .ConfigureProviderOptions(builder.Configuration.GetSection("CrestApps:AI:Providers")) // Optional AI features layered on top of the core AI + orchestration runtime. .AddMarkdown() .AddCopilotOrchestrator() @@ -192,7 +242,7 @@ ); // ============================================================================= -// 6. AI PROVIDERS +// 7. AI PROVIDERS // ============================================================================= // Register the AI completion providers you want to use. Each provider adds an // IAICompletionClient implementation that knows how to communicate with its @@ -205,13 +255,13 @@ // AddAISuite(ai => ai.AddAzureAIInference())— Azure AI Inference / GitHub Models // ============================================================================= // ============================================================================= -// 7. ELASTICSEARCH SERVICES +// 8. ELASTICSEARCH SERVICES // ============================================================================= // Keep each vector-search backend in its own group so it is obvious which block // to remove when the application does not use that provider. // ============================================================================= // ============================================================================= -// 8. AZURE AI SEARCH SERVICES +// 9. AZURE AI SEARCH SERVICES // ============================================================================= // This block mirrors the Elasticsearch group so each provider's registrations // stay together and are easy to remove independently. @@ -234,7 +284,7 @@ })); // ============================================================================= -// 9. MCP — MODEL CONTEXT PROTOCOL +// 10. MCP — MODEL CONTEXT PROTOCOL // ============================================================================= // MCP server endpoint configuration (using the ModelContextProtocol SDK). // This wires the CrestApps tool registry, prompt service, and resource service @@ -250,7 +300,7 @@ .WithCrestAppsHandlers(); // ============================================================================= -// 10. CUSTOM AI TOOLS +// 11. CUSTOM AI TOOLS // ============================================================================= // Register application-specific AI tools using the fluent builder pattern. // Tools marked as Selectable() are visible in the UI for user assignment to @@ -270,7 +320,7 @@ .Selectable(); // ============================================================================= -// 11. DATA STORE — YesSql with SQLite +// 12. DATA STORE — YesSql with SQLite // ============================================================================= // The framework does not impose a specific data store. You must provide // implementations of the store interfaces (IAIProfileManager, @@ -288,7 +338,7 @@ builder.Services.ConfigureOptions(); // ============================================================================= -// 12. BACKGROUND TASKS +// 13. BACKGROUND TASKS // ============================================================================= // These hosted services run periodic maintenance work. Implement your own // IHostedService or use these as reference implementations. @@ -305,14 +355,27 @@ var app = builder.Build(); -// YesSql schema initialization — creates tables on first run. -await app.Services.InitializeYesSqlSchemaAsync(); +try +{ + // YesSql schema initialization — creates tables on first run. + await app.Services.InitializeYesSqlSchemaAsync(); + + // Seed sample articles on first run. + await app.Services.SeedArticlesAsync(); +} +catch (Exception ex) +{ + var msg = $"[{DateTime.UtcNow:O}] Startup initialization failed:{Environment.NewLine}{ex}{Environment.NewLine}"; -// Seed sample articles on first run. -await app.Services.SeedArticlesAsync(); + try { File.AppendAllText(Path.Combine(crashLogDir, "crash.log"), msg); } catch { } + + Console.Error.Write(msg); + + throw; +} // ============================================================================= -// 13. MIDDLEWARE PIPELINE +// 14. MIDDLEWARE PIPELINE // ============================================================================= if (!app.Environment.IsDevelopment()) { @@ -378,4 +441,24 @@ app.MapControllerRoute(name: "areas", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); -await app.RunAsync(); +try +{ + await app.RunAsync(); +} +catch (Exception ex) +{ + var crashMessage = $"[{DateTime.UtcNow:O}] Host terminated unexpectedly:{Environment.NewLine}{ex}{Environment.NewLine}"; + + try + { + File.AppendAllText(Path.Combine(crashLogDir, "crash.log"), crashMessage); + } + catch + { + // Best-effort. + } + + Console.Error.Write(crashMessage); + + throw; +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsStore.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsStore.cs index 7b4eacc3..cb4bb8fc 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsStore.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/SiteSettingsStore.cs @@ -175,13 +175,26 @@ private JsonObject LoadFromDisk() return []; } - var json = File.ReadAllText(_filePath); - if (string.IsNullOrWhiteSpace(json)) + try { - return []; + var json = File.ReadAllText(_filePath); + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + return JsonNode.Parse(json) as JsonObject ?? []; } + catch (Exception ex) + { + // Log the parse failure so the operator can investigate, then start + // with an empty settings bag instead of crashing the host. + var crashPath = Path.Combine(Path.GetDirectoryName(_filePath) ?? ".", "site-settings-load-error.log"); + + try { File.WriteAllText(crashPath, $"[{DateTime.UtcNow:O}] Failed to load {_filePath}:{Environment.NewLine}{ex}"); } catch { } - return JsonNode.Parse(json) as JsonObject ?? []; + return []; + } } /// diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs index 46f44ba2..61842621 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Services/YesSqlServiceCollectionExtensions.cs @@ -141,7 +141,7 @@ public static async Task InitializeYesSqlSchemaAsync(this IServiceProvider servi await TryCreateTableAsync(() => schemaBuilder.CreateAIDeploymentIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIProfileTemplateIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionIndexSchemaAsync(storeOptions)); - await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionMetricsSchemaAsync(storeOptions: storeOptions)); + await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionMetricsSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAICompletionUsageIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionExtractedDataIndexSchemaAsync(storeOptions)); await TryCreateTableAsync(() => schemaBuilder.CreateAIChatSessionPromptIndexSchemaAsync(storeOptions)); diff --git a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs index c42b517d..b729089f 100644 --- a/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs +++ b/src/Stores/CrestApps.Core.Data.EntityCore/Services/EntityCoreAIChatSessionManager.cs @@ -73,8 +73,7 @@ public Task NewAsync(AIProfile profile, NewAIChatSessionContext c if (profile.Type == AIProfileType.Chat) { - var profileMetadata = profile.As(); - if (!string.IsNullOrWhiteSpace(profileMetadata.InitialPrompt)) + if (profile.TryGet(out var profileMetadata) && !string.IsNullOrWhiteSpace(profileMetadata.InitialPrompt)) { // Stage the initial prompt directly in the change tracker so that SaveAsync // commits both the session and this prompt atomically in a single transaction. diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/A2A/A2AConnectionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/A2A/A2AConnectionIndexSchemaBuilderExtensions.cs index cfac73aa..3ec9a634 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/A2A/A2AConnectionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/A2A/A2AConnectionIndexSchemaBuilderExtensions.cs @@ -4,13 +4,16 @@ namespace CrestApps.Core.Data.YesSql.Indexes.A2A; public static class A2AConnectionIndexSchemaBuilderExtensions { - public static Task CreateA2AConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateA2AConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(A2AConnectionIndex.ItemId), column => column.WithLength(26)) .Column(nameof(A2AConnectionIndex.DisplayText), column => column.WithLength(255)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_A2AConnection_DocumentId", "DocumentId"); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs index cf7efdec..53c478dc 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIDeploymentIndexSchemaBuilderExtensions.cs @@ -4,14 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AI; public static class AIDeploymentIndexSchemaBuilderExtensions { - public static Task CreateAIDeploymentIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIDeploymentIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIDeploymentIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIDeploymentIndex.Name), column => column.WithLength(255)) .Column(nameof(AIDeploymentIndex.Source), column => column.WithLength(255)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIDeployment_DocumentId", "DocumentId", nameof(AIDeploymentIndex.Name)); + table.CreateIndex("IDX_AIDeployment_Source", "DocumentId", nameof(AIDeploymentIndex.Source)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs index cee5460e..f94248dd 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileIndexSchemaBuilderExtensions.cs @@ -4,14 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AI; public static class AIProfileIndexSchemaBuilderExtensions { - public static Task CreateAIProfileIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIProfileIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIProfileIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIProfileIndex.Name), column => column.WithLength(255)) .Column(nameof(AIProfileIndex.Source), column => column.WithLength(255)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIProfile_DocumentId", "DocumentId", nameof(AIProfileIndex.Name)); + table.CreateIndex("IDX_AIProfile_Source", "DocumentId", nameof(AIProfileIndex.Source)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs index 92b5de00..7b0dbc96 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProfileTemplateIndexSchemaBuilderExtensions.cs @@ -4,14 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AI; public static class AIProfileTemplateIndexSchemaBuilderExtensions { - public static Task CreateAIProfileTemplateIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIProfileTemplateIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIProfileTemplateIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIProfileTemplateIndex.Name), column => column.WithLength(255)) .Column(nameof(AIProfileTemplateIndex.Source), column => column.WithLength(255)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIProfileTemplate_DocumentId", "DocumentId", nameof(AIProfileTemplateIndex.Name)); + table.CreateIndex("IDX_AIProfileTemplate_Source", "DocumentId", nameof(AIProfileTemplateIndex.Source)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs index b766fa2d..b9e41180 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AI/AIProviderConnectionIndexSchemaBuilderExtensions.cs @@ -4,14 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AI; public static class AIProviderConnectionIndexSchemaBuilderExtensions { - public static Task CreateAIProviderConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIProviderConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIProviderConnectionIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIProviderConnectionIndex.Name), column => column.WithLength(255)) .Column(nameof(AIProviderConnectionIndex.Source), column => column.WithLength(255)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIProviderConnection_DocumentId", "DocumentId", nameof(AIProviderConnectionIndex.Name)); + table.CreateIndex("IDX_AIProviderConnection_Source", "DocumentId", nameof(AIProviderConnectionIndex.Source)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs index 962e489a..c2587534 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionExtractedDataIndexSchemaBuilderExtensions.cs @@ -4,11 +4,9 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; public static class AIChatSessionExtractedDataIndexSchemaBuilderExtensions { - public static Task CreateAIChatSessionExtractedDataIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIChatSessionExtractedDataIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIChatSessionExtractedDataIndex.SessionId), column => column.WithLength(26)) .Column(nameof(AIChatSessionExtractedDataIndex.ProfileId), column => column.WithLength(26)) .Column(nameof(AIChatSessionExtractedDataIndex.SessionStartedUtc)) @@ -17,6 +15,11 @@ public static Task CreateAIChatSessionExtractedDataIndexSchemaAsync(this ISchema .Column(nameof(AIChatSessionExtractedDataIndex.FieldNames), column => column.WithLength(4000)) .Column(nameof(AIChatSessionExtractedDataIndex.ValuesText), column => column.WithLength(4000)) .Column(nameof(AIChatSessionExtractedDataIndex.UpdatedUtc)) - , collection: options.AICollectionName); + , collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIChatSessionExtractedData_DocumentId", "DocumentId", nameof(AIChatSessionExtractedDataIndex.SessionId), nameof(AIChatSessionExtractedDataIndex.ProfileId)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs index 97662fd6..06b2e910 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionIndexSchemaBuilderExtensions.cs @@ -4,17 +4,21 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; public static class AIChatSessionIndexSchemaBuilderExtensions { - public static Task CreateAIChatSessionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIChatSessionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIChatSessionIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIChatSessionIndex.SessionId), column => column.WithLength(26)) .Column(nameof(AIChatSessionIndex.ProfileId), column => column.WithLength(26)) .Column(nameof(AIChatSessionIndex.UserId), column => column.WithLength(255)) .Column(nameof(AIChatSessionIndex.Status)) .Column(nameof(AIChatSessionIndex.LastActivityUtc)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIChatSession_DocumentId", "DocumentId", nameof(AIChatSessionIndex.SessionId)); + table.CreateIndex("IDX_AIChatSession_ProfileId", "DocumentId", nameof(AIChatSessionIndex.ProfileId)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs index ed9e08d3..7376f2a7 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionMetricsIndexSchemaBuilderExtensions.cs @@ -4,16 +4,17 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; public static class AIChatSessionMetricsIndexSchemaBuilderExtensions { - public static async Task CreateAIChatSessionMetricsSchemaAsync(this ISchemaBuilder schemaBuilder, AIChatSessionMetricsIndexSchemaOptions options = null, YesSqlStoreOptions storeOptions = null) + public static async Task CreateAIChatSessionMetricsSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions storeOptions, AIChatSessionMetricsIndexSchemaOptions options = null) { options = NormalizeOptions(options, storeOptions); - await schemaBuilder.CreateAIChatSessionMetricsIndexTableAsync(options); - await schemaBuilder.CreateAIChatSessionMetricsNamedIndexesAsync(options); + await schemaBuilder.CreateAIChatSessionMetricsIndexTableAsync(storeOptions, options); + await schemaBuilder.CreateAIChatSessionMetricsNamedIndexesAsync(storeOptions, options); } - public static Task CreateAIChatSessionMetricsIndexTableAsync(this ISchemaBuilder schemaBuilder, AIChatSessionMetricsIndexSchemaOptions options = null, YesSqlStoreOptions storeOptions = null) + public static Task CreateAIChatSessionMetricsIndexTableAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions storeOptions, AIChatSessionMetricsIndexSchemaOptions options = null) { options = NormalizeOptions(options, storeOptions); + return schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIChatSessionMetricsIndex.SessionId), column => column.WithLength(options.SessionIdLength)) .Column(nameof(AIChatSessionMetricsIndex.ProfileId), column => column.WithLength(options.ProfileIdLength)) @@ -40,7 +41,7 @@ public static Task CreateAIChatSessionMetricsIndexTableAsync(this ISchemaBuilder collection: options.CollectionName); } - public static Task CreateAIChatSessionMetricsNamedIndexesAsync(this ISchemaBuilder schemaBuilder, AIChatSessionMetricsIndexSchemaOptions options = null, YesSqlStoreOptions storeOptions = null) + public static Task CreateAIChatSessionMetricsNamedIndexesAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions storeOptions, AIChatSessionMetricsIndexSchemaOptions options = null) { options = NormalizeOptions(options, storeOptions); @@ -64,26 +65,23 @@ public static Task CreateAIChatSessionMetricsNamedIndexesAsync(this ISchemaBuild collection: options.CollectionName)); } - public static Task AddAIChatSessionMetricsCompletionCountColumnAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions storeOptions = null) + public static Task AddAIChatSessionMetricsCompletionCountColumnAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions storeOptions) { - storeOptions ??= new YesSqlStoreOptions(); - return schemaBuilder.AlterIndexTableAsync(table => { table.AddColumn(nameof(AIChatSessionMetricsIndex.CompletionCount), column => column.WithDefault(0)); - }, collection: storeOptions.AICollectionName); + }, collection: storeOptions?.AICollectionName); } private static AIChatSessionMetricsIndexSchemaOptions NormalizeOptions(AIChatSessionMetricsIndexSchemaOptions options, YesSqlStoreOptions storeOptions) { - storeOptions ??= new YesSqlStoreOptions(); options ??= new AIChatSessionMetricsIndexSchemaOptions(); if (options.CollectionName == null) { return new AIChatSessionMetricsIndexSchemaOptions { - CollectionName = storeOptions.AICollectionName, + CollectionName = storeOptions?.AICollectionName, SessionIdLength = options.SessionIdLength, ProfileIdLength = options.ProfileIdLength, VisitorIdLength = options.VisitorIdLength, diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs index 9de231ff..880948dc 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AIChatSessionPromptIndexSchemaBuilderExtensions.cs @@ -4,14 +4,17 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; public static class AIChatSessionPromptIndexSchemaBuilderExtensions { - public static Task CreateAIChatSessionPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIChatSessionPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIChatSessionPromptIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIChatSessionPromptIndex.SessionId), column => column.WithLength(26)) .Column(nameof(AIChatSessionPromptIndex.Role), column => column.WithLength(50)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIChatSessionPrompt_DocumentId", "DocumentId", nameof(AIChatSessionPromptIndex.SessionId)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs index 8befb82d..79d077c2 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIChat/AICompletionUsageIndexSchemaBuilderExtensions.cs @@ -4,11 +4,9 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AIChat; public static class AICompletionUsageIndexSchemaBuilderExtensions { - public static Task CreateAICompletionUsageIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAICompletionUsageIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AICompletionUsageIndex.ContextType), column => column.WithLength(64)) .Column(nameof(AICompletionUsageIndex.SessionId), column => column.WithLength(26)) .Column(nameof(AICompletionUsageIndex.ProfileId), column => column.WithLength(26)) @@ -30,6 +28,12 @@ public static Task CreateAICompletionUsageIndexSchemaAsync(this ISchemaBuilder s .Column(nameof(AICompletionUsageIndex.TotalTokenCount)) .Column(nameof(AICompletionUsageIndex.ResponseLatencyMs)) .Column(nameof(AICompletionUsageIndex.CreatedUtc)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AICompletionUsage_DocumentId", "DocumentId", nameof(AICompletionUsageIndex.SessionId), nameof(AICompletionUsageIndex.ProfileId)); + table.CreateIndex("IDX_AICompletionUsage_UserId", "DocumentId", nameof(AICompletionUsageIndex.UserId), nameof(AICompletionUsageIndex.CreatedUtc)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs index 49843322..569ff47c 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/AIMemory/AIMemoryEntryIndexSchemaBuilderExtensions.cs @@ -4,14 +4,17 @@ namespace CrestApps.Core.Data.YesSql.Indexes.AIMemory; public static class AIMemoryEntryIndexSchemaBuilderExtensions { - public static Task CreateAIMemoryEntryIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIMemoryEntryIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIMemoryEntryIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIMemoryEntryIndex.UserId), column => column.WithLength(255)) .Column(nameof(AIMemoryEntryIndex.Name), column => column.WithLength(255)), - collection: options.AIMemoryCollectionName); + collection: options?.AIMemoryCollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIMemoryEntry_DocumentId", "DocumentId", nameof(AIMemoryEntryIndex.UserId), nameof(AIMemoryEntryIndex.Name)); + }, collection: options?.AIMemoryCollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs index 17844307..76fb51b3 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionIndexSchemaBuilderExtensions.cs @@ -4,15 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.ChatInteractions; public static class ChatInteractionIndexSchemaBuilderExtensions { - public static Task CreateChatInteractionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateChatInteractionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(ChatInteractionIndex.ItemId), column => column.WithLength(26)) .Column(nameof(ChatInteractionIndex.UserId), column => column.WithLength(255)) .Column(nameof(ChatInteractionIndex.Title), column => column.WithLength(255)) .Column(nameof(ChatInteractionIndex.CreatedUtc)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_ChatInteraction_DocumentId", "DocumentId", nameof(ChatInteractionIndex.UserId)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs index 82d9c58e..42bdfb95 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/ChatInteractions/ChatInteractionPromptIndexSchemaBuilderExtensions.cs @@ -4,15 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.ChatInteractions; public static class ChatInteractionPromptIndexSchemaBuilderExtensions { - public static Task CreateChatInteractionPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateChatInteractionPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(ChatInteractionPromptIndex.ItemId), column => column.WithLength(26)) .Column(nameof(ChatInteractionPromptIndex.ChatInteractionId), column => column.WithLength(26)) .Column(nameof(ChatInteractionPromptIndex.Role), column => column.WithLength(50)) .Column(nameof(ChatInteractionPromptIndex.CreatedUtc)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_ChatInteractionPrompt_DocumentId", "DocumentId", nameof(ChatInteractionPromptIndex.ChatInteractionId)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs index 09a10dc8..1d040ec1 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/DataSources/AIDataSourceIndexSchemaBuilderExtensions.cs @@ -4,14 +4,17 @@ namespace CrestApps.Core.Data.YesSql.Indexes.DataSources; public static class AIDataSourceIndexSchemaBuilderExtensions { - public static Task CreateAIDataSourceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIDataSourceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIDataSourceIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIDataSourceIndex.DisplayText), column => column.WithLength(255)) .Column(nameof(AIDataSourceIndex.SourceIndexProfileName), column => column.WithLength(255)), - collection: options.AIDocsCollectionName); + collection: options?.AIDocsCollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIDataSource_DocumentId", "DocumentId"); + }, collection: options?.AIDocsCollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs index 36a315e4..c4c894b8 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentChunkIndexSchemaBuilderExtensions.cs @@ -4,16 +4,20 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Indexing; public static class AIDocumentChunkIndexSchemaBuilderExtensions { - public static Task CreateAIDocumentChunkIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIDocumentChunkIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIDocumentChunkIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIDocumentChunkIndex.AIDocumentId), column => column.WithLength(26)) .Column(nameof(AIDocumentChunkIndex.ReferenceId), column => column.WithLength(26)) .Column(nameof(AIDocumentChunkIndex.ReferenceType), column => column.WithLength(50)) .Column(nameof(AIDocumentChunkIndex.Index)), - collection: options.AIDocsCollectionName); + collection: options?.AIDocsCollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIDocumentChunk_DocumentId", "DocumentId", nameof(AIDocumentChunkIndex.AIDocumentId)); + table.CreateIndex("IDX_AIDocumentChunk_Reference", "DocumentId", nameof(AIDocumentChunkIndex.ReferenceId), nameof(AIDocumentChunkIndex.ReferenceType)); + }, collection: options?.AIDocsCollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs index 32848382..61045eab 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/AIDocumentIndexSchemaBuilderExtensions.cs @@ -4,15 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Indexing; public static class AIDocumentIndexSchemaBuilderExtensions { - public static Task CreateAIDocumentIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateAIDocumentIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(AIDocumentIndex.ItemId), column => column.WithLength(26)) .Column(nameof(AIDocumentIndex.ReferenceId), column => column.WithLength(26)) .Column(nameof(AIDocumentIndex.ReferenceType), column => column.WithLength(50)) .Column(nameof(AIDocumentIndex.FileName), column => column.WithLength(255)), - collection: options.AIDocsCollectionName); + collection: options?.AIDocsCollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_AIDocument_DocumentId", "DocumentId", nameof(AIDocumentIndex.ReferenceId), nameof(AIDocumentIndex.ReferenceType)); + }, collection: options?.AIDocsCollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs index 23b8f06f..2998a619 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Indexing/SearchIndexProfileIndexSchemaBuilderExtensions.cs @@ -4,17 +4,21 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Indexing; public static class SearchIndexProfileIndexSchemaBuilderExtensions { - public static Task CreateSearchIndexProfileIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateSearchIndexProfileIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(SearchIndexProfileIndex.ItemId), column => column.WithLength(26)) .Column(nameof(SearchIndexProfileIndex.Name), column => column.WithLength(255)) .Column(nameof(SearchIndexProfileIndex.ProviderName), column => column.WithLength(255)) .Column(nameof(SearchIndexProfileIndex.IndexName), column => column.WithLength(255)) .Column(nameof(SearchIndexProfileIndex.IndexFullName), column => column.WithLength(767)) .Column(nameof(SearchIndexProfileIndex.Type), column => column.WithLength(50)), - collection: options.DefaultCollectionName); + collection: options?.DefaultCollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_SearchIndexProfile_DocumentId", "DocumentId", nameof(SearchIndexProfileIndex.Name)); + table.CreateIndex("IDX_SearchIndexProfile_Type", "DocumentId", nameof(SearchIndexProfileIndex.Type)); + }, collection: options?.DefaultCollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpConnectionIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpConnectionIndexSchemaBuilderExtensions.cs index 248b9732..3a622ce6 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpConnectionIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpConnectionIndexSchemaBuilderExtensions.cs @@ -4,14 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; public static class McpConnectionIndexSchemaBuilderExtensions { - public static Task CreateMcpConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateMcpConnectionIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(McpConnectionIndex.ItemId), column => column.WithLength(26)) .Column(nameof(McpConnectionIndex.DisplayText), column => column.WithLength(255)) .Column(nameof(McpConnectionIndex.Source), column => column.WithLength(50)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_McpConnection_DocumentId", "DocumentId"); + table.CreateIndex("IDX_McpConnection_Source", "DocumentId", nameof(McpConnectionIndex.Source)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpPromptIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpPromptIndexSchemaBuilderExtensions.cs index 9af2313f..57c51170 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpPromptIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpPromptIndexSchemaBuilderExtensions.cs @@ -4,13 +4,16 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; public static class McpPromptIndexSchemaBuilderExtensions { - public static Task CreateMcpPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateMcpPromptIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(McpPromptIndex.ItemId), column => column.WithLength(26)) .Column(nameof(McpPromptIndex.Name), column => column.WithLength(255)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_McpPrompt_DocumentId", "DocumentId", nameof(McpPromptIndex.Name)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpResourceIndexSchemaBuilderExtensions.cs b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpResourceIndexSchemaBuilderExtensions.cs index 589f095c..e33ac794 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpResourceIndexSchemaBuilderExtensions.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Indexes/Mcp/McpResourceIndexSchemaBuilderExtensions.cs @@ -4,14 +4,18 @@ namespace CrestApps.Core.Data.YesSql.Indexes.Mcp; public static class McpResourceIndexSchemaBuilderExtensions { - public static Task CreateMcpResourceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options = null) + public static async Task CreateMcpResourceIndexSchemaAsync(this ISchemaBuilder schemaBuilder, YesSqlStoreOptions options) { - options ??= new YesSqlStoreOptions(); - - return schemaBuilder.CreateMapIndexTableAsync(table => table + await schemaBuilder.CreateMapIndexTableAsync(table => table .Column(nameof(McpResourceIndex.ItemId), column => column.WithLength(26)) .Column(nameof(McpResourceIndex.DisplayText), column => column.WithLength(255)) .Column(nameof(McpResourceIndex.Source), column => column.WithLength(50)), - collection: options.AICollectionName); + collection: options?.AICollectionName); + + await schemaBuilder.AlterIndexTableAsync(table => + { + table.CreateIndex("IDX_McpResource_DocumentId", "DocumentId"); + table.CreateIndex("IDX_McpResource_Source", "DocumentId", nameof(McpResourceIndex.Source)); + }, collection: options?.AICollectionName); } } diff --git a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs index 12c0b3d1..934a89d5 100644 --- a/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs +++ b/src/Stores/CrestApps.Core.Data.YesSql/Services/YesSqlAIChatSessionManager.cs @@ -107,9 +107,7 @@ public async Task NewAsync(AIProfile profile, NewAIChatSessionCon if (profile.Type == AIProfileType.Chat) { - var profileMetadata = profile.As(); - - if (!string.IsNullOrWhiteSpace(profileMetadata.InitialPrompt)) + if (profile.TryGet(out var profileMetadata) && !string.IsNullOrWhiteSpace(profileMetadata.InitialPrompt)) { await _promptStore.CreateAsync(new AIChatSessionPrompt { diff --git a/tests/CrestApps.Core.Tests/Core/Services/EmbeddedResourceAIProfileTemplateProviderTests.cs b/tests/CrestApps.Core.Tests/Core/Services/EmbeddedResourceAIProfileTemplateProviderTests.cs index 9793622c..7da2c480 100644 --- a/tests/CrestApps.Core.Tests/Core/Services/EmbeddedResourceAIProfileTemplateProviderTests.cs +++ b/tests/CrestApps.Core.Tests/Core/Services/EmbeddedResourceAIProfileTemplateProviderTests.cs @@ -31,7 +31,7 @@ public async Task GetTemplatesAsync_MapsFrontMatterToProfileMetadata() var templates = await provider.GetTemplatesAsync(); var template = templates.Single(t => t.ItemId == "chat-session-summarizer"); - var metadata = template.As(); + var metadata = template.GetOrCreate(); Assert.Equal(AIProfileType.TemplatePrompt, metadata.ProfileType); Assert.Equal(0.3f, metadata.Temperature); diff --git a/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs b/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs index ca5b298d..a682eaf6 100644 --- a/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Chat.Copilot/MvcAITemplateViewModelCopilotTests.cs @@ -103,6 +103,6 @@ public void ApplyTo_WhenProfileSource_ShouldPersistSharedMemorySettings() model.ApplyTo(template); - Assert.True(template.As().EnableUserMemory ?? false); + Assert.True(template.GetOrCreate().EnableUserMemory ?? false); } } diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProfileViewModelTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProfileViewModelTests.cs index 315e2afc..fb946bbb 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProfileViewModelTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProfileViewModelTests.cs @@ -187,7 +187,7 @@ public void ApplyTo_WritesSettingsAndMetadataToMatchingStores() Assert.Equal(AISessionTitleType.Generated, profile.TitleType); Assert.Null(profile.WelcomeMessage); - var profileMetadata = profile.As(); + var profileMetadata = profile.GetOrCreate(); Assert.Equal("system", profileMetadata.SystemMessage); Assert.Equal("hello", profileMetadata.InitialPrompt); Assert.Equal(0.2f, profileMetadata.Temperature); @@ -203,25 +203,25 @@ public void ApplyTo_WritesSettingsAndMetadataToMatchingStores() Assert.True(profileSettings.IsListable); Assert.False(profileSettings.IsRemovable); - Assert.Equal(["tool-1"], profile.As().Names); - Assert.Equal(["agent-1"], profile.As().Names); - Assert.Equal("data-source-1", profile.As().DataSourceId); - Assert.Equal(["a2a-1"], profile.As().ConnectionIds); - Assert.Equal(["mcp-1"], profile.As().ConnectionIds); + Assert.Equal(["tool-1"], profile.GetOrCreate().Names); + Assert.Equal(["agent-1"], profile.GetOrCreate().Names); + Assert.Equal("data-source-1", profile.GetOrCreate().DataSourceId); + Assert.Equal(["a2a-1"], profile.GetOrCreate().ConnectionIds); + Assert.Equal(["mcp-1"], profile.GetOrCreate().ConnectionIds); - var ragMetadata = profile.As(); + var ragMetadata = profile.GetOrCreate(); Assert.Equal(4, ragMetadata.Strictness); Assert.Equal(8, ragMetadata.TopNDocuments); Assert.True(ragMetadata.IsInScope); Assert.Equal("category eq 'docs'", ragMetadata.Filter); - var promptMetadata = profile.As(); + var promptMetadata = profile.GetOrCreate(); var promptTemplate = Assert.Single(promptMetadata.Templates); Assert.Equal("template-1", promptTemplate.TemplateId); Assert.Equal("friendly", Assert.IsType(promptTemplate.Parameters["tone"])); - Assert.Equal(6, profile.As().DocumentTopN); - Assert.True(profile.As().AllowSessionDocuments); + Assert.Equal(6, profile.GetOrCreate().DocumentTopN); + Assert.True(profile.GetOrCreate().AllowSessionDocuments); var extractionSettings = profile.GetSettings(); Assert.True(extractionSettings.EnableDataExtraction); @@ -229,7 +229,7 @@ public void ApplyTo_WritesSettingsAndMetadataToMatchingStores() Assert.Equal(12, extractionSettings.SessionInactivityTimeoutInMinutes); Assert.Single(extractionSettings.DataExtractionEntries); - var analyticsMetadata = profile.As(); + var analyticsMetadata = profile.GetOrCreate(); Assert.True(analyticsMetadata.EnableSessionMetrics); Assert.False(analyticsMetadata.EnableAIResolutionDetection); Assert.True(analyticsMetadata.EnableConversionMetrics); @@ -250,7 +250,7 @@ public void ApplyTo_WritesSettingsAndMetadataToMatchingStores() option => Assert.Equal("one", option.Value), option => Assert.Equal("two", option.Value)); - Assert.True(profile.As().EnableUserMemory ?? false); + Assert.True(profile.GetOrCreate().EnableUserMemory ?? false); Assert.False(profile.TryGet(out _)); @@ -333,8 +333,8 @@ public void As_ShouldReadDictionaryBackedExtensionData() }, }; - Assert.Equal("dictionary-source", profile.As().DataSourceId); - Assert.True(profile.As().IsInScope); + Assert.Equal("dictionary-source", profile.GetOrCreate().DataSourceId); + Assert.True(profile.GetOrCreate().IsInScope); } [Fact] @@ -364,6 +364,6 @@ public void JsonSerialization_ShouldKeepSettingsNestedAndMetadataFlattened() Assert.True(reloaded.Properties.ContainsKey(nameof(DataSourceMetadata))); Assert.True(reloaded.GetSettings().IsListable); Assert.False(reloaded.GetSettings().IsRemovable); - Assert.Equal("serialized-source", reloaded.As().DataSourceId); + Assert.Equal("serialized-source", reloaded.GetOrCreate().DataSourceId); } } diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs index f7d1c132..e889c079 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/AIProviderConnectionOptionsTests.cs @@ -391,6 +391,7 @@ private static DefaultAIProviderConnectionStore CreateConnectionStore( sources.Add(new ConfigurationAIProviderConnectionSource( configuration, + TimeProvider.System, Options.Create(catalogOptions ?? new AIProviderConnectionCatalogOptions()), NullLogger.Instance)); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs index 3e65efc6..f9c60fd1 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ChatInteractionHubTests.cs @@ -9,7 +9,6 @@ using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Services; using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Hubs; -using CrestApps.Core.Mvc.Web.Areas.ChatInteractions.Models; using CrestApps.Core.Mvc.Web.Services; using CrestApps.Core.Services; using Microsoft.AspNetCore.SignalR; @@ -57,7 +56,7 @@ public async Task SaveSettings_PersistsCoreAndTemplateSettings() await hub.SaveSettings(interaction.ItemId, json.RootElement.Clone()); Assert.Equal("Updated title", interaction.Title); Assert.Equal(["agent-a", "agent-b"], interaction.AgentNames); - var promptTemplateMetadata = interaction.As(); + var promptTemplateMetadata = interaction.GetOrCreate(); var template = Assert.Single(promptTemplateMetadata.Templates); Assert.Equal("template-1", template.TemplateId); Assert.NotNull(template.Parameters); @@ -100,9 +99,9 @@ public async Task SaveSettings_WithDataSourceSettings_PersistsRagMetadata() } """); await hub.SaveSettings(interaction.ItemId, json.RootElement.Clone()); - var dataSourceMetadata = interaction.As(); + var dataSourceMetadata = interaction.GetOrCreate(); Assert.Equal("datasource-1", dataSourceMetadata.DataSourceId); - var ragMetadata = interaction.As(); + var ragMetadata = interaction.GetOrCreate(); Assert.Equal(4, ragMetadata.Strictness); Assert.Equal(7, ragMetadata.TopNDocuments); Assert.False(ragMetadata.IsInScope); diff --git a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs index 94ab56f4..fd4e4cac 100644 --- a/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs +++ b/tests/CrestApps.Core.Tests/Framework/Mvc/ConfigurationAIDeploymentCatalogTests.cs @@ -180,6 +180,7 @@ private static DefaultAIDeploymentStore CreateStore( sources.Add(new ConfigurationAIDeploymentSource( configuration, + TimeProvider.System, Options.Create(aiOptions), Options.Create(catalogOptions ?? new AIDeploymentCatalogOptions()), NullLogger.Instance)); diff --git a/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs b/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs index c47192f3..fe1d780c 100644 --- a/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs +++ b/tests/CrestApps.Core.Tests/Mcp/SseClientTransportProviderTests.cs @@ -68,7 +68,7 @@ public async Task GetAsync_ApiKey_SetsCorrectHeader( { // Arrange var connection = CreateConnection(McpClientAuthenticationType.ApiKey); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.ApiKeyHeaderName = headerName; metadata.ApiKeyPrefix = prefix; metadata.ApiKey = apiKey; @@ -91,7 +91,7 @@ public async Task GetAsync_Basic_SetsBase64AuthorizationHeader() var username = "testuser"; var password = "testpass"; var connection = CreateConnection(McpClientAuthenticationType.Basic); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.BasicUsername = username; metadata.BasicPassword = password; connection.Put(metadata); @@ -112,7 +112,7 @@ public async Task GetAsync_Basic_WithEmptyPassword_SetsHeaderWithEmptyPassword() { // Arrange var connection = CreateConnection(McpClientAuthenticationType.Basic); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.BasicUsername = "testuser"; metadata.BasicPassword = null; connection.Put(metadata); @@ -139,7 +139,7 @@ public async Task GetAsync_OAuth2ClientCredentials_AcquiresTokenAndSetsBearerHea var scopes = "read write"; var connection = CreateConnection(McpClientAuthenticationType.OAuth2ClientCredentials); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.OAuth2TokenEndpoint = tokenEndpoint; metadata.OAuth2ClientId = clientId; metadata.OAuth2ClientSecret = clientSecret; @@ -175,7 +175,7 @@ public async Task GetAsync_OAuth2PrivateKeyJwt_AcquiresTokenAndSetsBearerHeader( var scopes = "api"; var connection = CreateConnection(McpClientAuthenticationType.OAuth2PrivateKeyJwt); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.OAuth2TokenEndpoint = tokenEndpoint; metadata.OAuth2ClientId = clientId; metadata.OAuth2PrivateKey = privateKey; @@ -217,7 +217,7 @@ public async Task GetAsync_OAuth2Mtls_AcquiresTokenAndSetsBearerHeader() var protector = new PassthroughDataProtectionProvider().CreateProtector("test"); var connection = CreateConnection(McpClientAuthenticationType.OAuth2Mtls); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.OAuth2TokenEndpoint = tokenEndpoint; metadata.OAuth2ClientId = clientId; metadata.OAuth2ClientCertificate = protector.Protect(certBase64); @@ -251,7 +251,7 @@ public async Task GetAsync_CustomHeaders_PassesAllHeaders() { // Arrange var connection = CreateConnection(McpClientAuthenticationType.CustomHeaders); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.AdditionalHeaders = new Dictionary { ["X-Custom-Header"] = "custom-value", @@ -277,7 +277,7 @@ public async Task GetAsync_CustomHeaders_WithNullHeaders_ReturnsEmptyHeaders() { // Arrange var connection = CreateConnection(McpClientAuthenticationType.CustomHeaders); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.AdditionalHeaders = null; connection.Put(metadata); @@ -295,7 +295,7 @@ public async Task GetAsync_OAuth2ClientCredentials_WhenTokenAcquisitionFails_Thr { // Arrange var connection = CreateConnection(McpClientAuthenticationType.OAuth2ClientCredentials); - var metadata = connection.As(); + var metadata = connection.GetOrCreate(); metadata.OAuth2TokenEndpoint = "https://auth.example.com/token"; metadata.OAuth2ClientId = "client-id"; metadata.OAuth2ClientSecret = "client-secret";