diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ElasticsearchSourceMetadata.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ElasticsearchSourceMetadata.cs index e9e29264..9283a34b 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ElasticsearchSourceMetadata.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/ElasticsearchSourceMetadata.cs @@ -5,6 +5,16 @@ namespace CrestApps.Core.AI.Models; /// public sealed class ElasticsearchSourceMetadata { + /// + /// The self-managed Elasticsearch environment type value. + /// + public const string SelfManagedEnvironmentType = "SelfManaged"; + + /// + /// The Elastic Cloud hosted environment type value. + /// + public const string CloudHostedEnvironmentType = "CloudHosted"; + /// /// The anonymous Elasticsearch authentication type value. /// @@ -15,11 +25,36 @@ public sealed class ElasticsearchSourceMetadata /// public const string BasicAuthenticationType = "Basic"; + /// + /// The Elasticsearch API key authentication type value. + /// + public const string ApiKeyAuthenticationType = "ApiKey"; + + /// + /// The Elasticsearch base64-encoded API key authentication type value. + /// + public const string Base64ApiKeyAuthenticationType = "Base64ApiKey"; + + /// + /// The Elasticsearch key identifier plus API key authentication type value. + /// + public const string KeyIdAndKeyAuthenticationType = "KeyIdAndKey"; + + /// + /// Gets or sets the Elasticsearch environment type. + /// + public string EnvironmentType { get; set; } + /// /// Gets or sets the Elasticsearch endpoint URL. /// public string Url { get; set; } + /// + /// Gets or sets the Elastic Cloud deployment identifier. + /// + public string CloudId { get; set; } + /// /// Gets or sets the authentication type. /// @@ -40,11 +75,48 @@ public sealed class ElasticsearchSourceMetadata /// public string Password { get; set; } + /// + /// Gets or sets the optional protected Elasticsearch API key value. + /// + public string ApiKey { get; set; } + + /// + /// Gets or sets the optional protected base64-encoded Elasticsearch API key value. + /// + public string Base64ApiKey { get; set; } + + /// + /// Gets or sets the optional Elasticsearch API key identifier. + /// + public string ApiKeyId { get; set; } + /// /// Gets or sets the optional TLS certificate fingerprint. /// public string CertificateFingerprint { get; set; } + /// + /// Gets the normalized environment type. + /// + public string GetEnvironmentType() + { + if (string.IsNullOrWhiteSpace(EnvironmentType)) + { + return string.IsNullOrWhiteSpace(CloudId) + ? SelfManagedEnvironmentType + : CloudHostedEnvironmentType; + } + + var environmentType = EnvironmentType.Trim(); + + if (string.Equals(environmentType, CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase)) + { + return CloudHostedEnvironmentType; + } + + return SelfManagedEnvironmentType; + } + /// /// Gets the normalized authentication type. /// @@ -52,13 +124,48 @@ public string GetAuthenticationType() { if (string.IsNullOrWhiteSpace(AuthenticationType)) { - return string.IsNullOrWhiteSpace(Username) && string.IsNullOrWhiteSpace(Password) - ? NoneAuthenticationType - : BasicAuthenticationType; + if (!string.IsNullOrWhiteSpace(Username) || !string.IsNullOrWhiteSpace(Password)) + { + return BasicAuthenticationType; + } + + if (!string.IsNullOrWhiteSpace(ApiKeyId) || !string.IsNullOrWhiteSpace(ApiKey)) + { + return string.IsNullOrWhiteSpace(ApiKeyId) + ? ApiKeyAuthenticationType + : KeyIdAndKeyAuthenticationType; + } + + if (!string.IsNullOrWhiteSpace(Base64ApiKey)) + { + return Base64ApiKeyAuthenticationType; + } + + return NoneAuthenticationType; + } + + var authenticationType = AuthenticationType.Trim(); + + if (string.Equals(authenticationType, BasicAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return BasicAuthenticationType; + } + + if (string.Equals(authenticationType, ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return Base64ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return KeyIdAndKeyAuthenticationType; } - return string.Equals(AuthenticationType.Trim(), BasicAuthenticationType, StringComparison.OrdinalIgnoreCase) - ? BasicAuthenticationType - : NoneAuthenticationType; + return NoneAuthenticationType; } } diff --git a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceDescriptor.cs b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceDescriptor.cs index f787f989..fb17f156 100644 --- a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceDescriptor.cs +++ b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceDescriptor.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Localization; - namespace CrestApps.Core.Infrastructure.Indexing; /// @@ -22,15 +20,15 @@ public sealed class IndexProfileSourceDescriptor /// /// Gets or sets the human-readable display name of the search provider. /// - public LocalizedString ProviderDisplayName { get; set; } + public string ProviderDisplayName { get; set; } /// /// Gets or sets the human-readable display name shown in the UI for this source descriptor. /// - public LocalizedString DisplayName { get; set; } + public string DisplayName { get; set; } /// /// Gets or sets a short description of this source descriptor shown in the UI. /// - public LocalizedString Description { get; set; } + public string Description { get; set; } } diff --git a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceOptions.cs b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceOptions.cs index b9d2b459..1bbd437c 100644 --- a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceOptions.cs +++ b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/IndexProfileSourceOptions.cs @@ -14,6 +14,22 @@ public sealed class IndexProfileSourceOptions /// public List Sources { get; } = []; + /// + /// Adds or update. + /// + /// The provider name. + /// The provider display name. + /// The type. + /// The action used to configure. + public void AddOrUpdate( + string providerName, + string providerDisplayName, + string type, + Action configure = null) + { + AddOrUpdate(providerName, new LocalizedString(providerDisplayName, providerDisplayName), type, configure); + } + /// /// Adds or update. /// @@ -38,10 +54,10 @@ public void AddOrUpdate( descriptor ??= new IndexProfileSourceDescriptor { ProviderName = providerName, - ProviderDisplayName = providerDisplayName, + ProviderDisplayName = providerDisplayName.Value, Type = type, - DisplayName = new LocalizedString(type, type), - Description = new LocalizedString(type, type), + DisplayName = type, + Description = type, }; configure?.Invoke(descriptor); diff --git a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md index 602dbc96..ae74353e 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -77,9 +77,11 @@ description: Initial standalone release notes for the CrestApps.Core repository. - caps the total uploaded vision-image bytes loaded into a single multimodal request through `ChatDocumentsOptions.MaxVisionInputBytesPerRequest`, removes the extra `MemoryStream` copy when attaching those images, and documents how to resolve a vision-capable chat client for direct image-description requests - adds the standalone `CrestApps.Core.AI.Resilience` package with opt-in Microsoft.Extensions.AI builder resilience extensions for chat, embeddings, image generation, speech-to-text, and text-to-speech clients, including `UseDefaultResilience()` for provider `429 Too Many Requests` retries and `UseResilience(...)` for custom Polly/Microsoft resilience pipelines; the docs now include a dedicated AI Resilience page, the default retry schedule uses exponential backoff with jitter (about 1-2, 2-4, 4-8, 8-16, and 16-32 seconds across five retries), framework-owned completion clients and utility-deployment chat flows apply the default retry policy automatically, host-created clients remain opt-in, builder examples require `Build(serviceProvider)` instead of `Build(null)`, and Azure OpenAI exposes shared SDK retry settings through `CrestApps:AI:AzureClient` with matching five-retry exponential defaults - adds `IAIClientFactory` overloads that accept builder-configuration delegates for chat, embeddings, image generation, speech-to-text, and text-to-speech clients, so callers can apply middleware such as `UseDefaultResilience()` while the factory owns the final `Build(serviceProvider)` step +- expands Elasticsearch AI data source authentication beyond Basic by adding Elastic Cloud ID support plus `ApiKey`, `Base64ApiKey`, and `KeyIdAndKey` modes with protected per-source secrets in the MVC and Blazor editors and the shared Elasticsearch client factory - adds `CrestApps.Core.PostgreSQL` and `CrestApps.Core.AI.PostgreSQL` packages providing a lightweight PostgreSQL + pgvector vector search backend as an alternative to Elasticsearch and Azure AI Search, registers the same keyed services (`ISearchIndexManager`, `ISearchDocumentManager`, `IDataSourceContentManager`, `IDataSourceDocumentReader`, `IODataFilterTranslator`) under the `"PostgreSQL"` provider name, supports `AddAIDocuments()`, `AddAIDataSources()`, and `AddAIMemory()` builder extensions, and integrates into both MVC and Blazor sample hosts - fixes hosted document and data-source indexing flows so background workers create a scoped service provider before resolving scoped indexing services, preventing upload-triggered failures and similar nightly alignment lifetime issues - standardizes Azure AI Search configuration on top-level `AuthenticationType`, `ApiKey`, `IdentityClientId`, and `IndexPrefix` settings under `CrestApps:AzureAISearch`, and refreshes the sample host / docs examples to list the full supported option set in one place +- adds an explicit Elasticsearch data-source environment selector (`SelfManaged` vs `CloudHosted`) so the MVC and Blazor editors show either `Url` or `CloudId` as appropriate and validation now requires the matching field for the chosen environment - replaces per-turn raw image byte injection with an analyze-once-at-upload strategy: `IImageAnalysisService` calls a vision model to extract caption, OCR text, and detected entities when images are uploaded, stores the results as `AIDocumentChunk` records searchable via `read_document` and `search_documents`, adds `inspect_image` as an on-demand tool for pixel-level inspection when the text analysis is insufficient, removes `BuildVisionUserContentsAsync` from `DocumentOrchestrationHandler` so image bytes are never attached to every user message, and updates the document-availability prompt to guide the model toward text-based tools first - adds a defense-in-depth prompt security layer for AI Profile chat experiences with normalized regex-rule evaluation, weighted risk scoring, profile-level overrides, output filtering, audit logging, and documentation for remaining regex-based limitations - adds AI tool dependency registration through the fluent `AIToolBuilder`, automatically expands selected tool sets to include registered dependencies during profile/system tool resolution, ignores missing dependencies safely, and adds focused unit coverage for recursive, shared, and circular dependency graphs diff --git a/src/CrestApps.Core.Docs/docs/data-sources/elasticsearch.md b/src/CrestApps.Core.Docs/docs/data-sources/elasticsearch.md index 8a75cb93..88f9dd55 100644 --- a/src/CrestApps.Core.Docs/docs/data-sources/elasticsearch.md +++ b/src/CrestApps.Core.Docs/docs/data-sources/elasticsearch.md @@ -32,8 +32,13 @@ builder.Services.AddCoreElasticsearchServices(); "Search": { "Elasticsearch": { "Url": "https://localhost:9200", + "CloudId": "", + "AuthenticationType": "Basic", "Username": "elastic", "Password": "your-password", + "ApiKey": "", + "Base64ApiKey": "", + "ApiKeyId": "", "CertificateFingerprint": "AA:BB:CC:..." } } @@ -46,8 +51,13 @@ builder.Services.AddCoreElasticsearchServices(); | Property | Type | Description | |----------|------|-------------| | `Url` | `string` | Elasticsearch endpoint URL | +| `CloudId` | `string` | Elastic Cloud deployment identifier (optional alternative to `Url`) | +| `AuthenticationType` | `string` | `None`, `Basic`, `ApiKey`, `Base64ApiKey`, or `KeyIdAndKey` | | `Username` | `string` | Basic auth username (optional) | | `Password` | `string` | Basic auth password (optional) | +| `ApiKey` | `string` | Raw API key value for `ApiKey` auth, or the key portion for `KeyIdAndKey` | +| `Base64ApiKey` | `string` | Base64-encoded API key value for `Base64ApiKey` auth | +| `ApiKeyId` | `string` | API key identifier for `KeyIdAndKey` auth | | `CertificateFingerprint` | `string` | TLS certificate fingerprint for verification (optional) | ## Services Registered (Keyed by `"Elasticsearch"`) @@ -81,12 +91,19 @@ Override `IAIDataSourceIndexingQueue` when you need a durable or distributed que When an `AIDataSource` uses `SourceType = "Elasticsearch"`, the mapping reads documents from a remote Elasticsearch index using source-specific settings stored on the `AIDataSource` itself: - `Url` -- `AuthenticationType` (`None` or `Basic`) +- `CloudId` +- `EnvironmentType` (`SelfManaged` or `CloudHosted`) +- `AuthenticationType` (`None`, `Basic`, `ApiKey`, `Base64ApiKey`, or `KeyIdAndKey`) - `IndexName` - `Username` (when `AuthenticationType = "Basic"`) - `Password` (protected at rest when `AuthenticationType = "Basic"`) +- `ApiKey` (protected at rest when `AuthenticationType = "ApiKey"` or `KeyIdAndKey`) +- `Base64ApiKey` (protected at rest when `AuthenticationType = "Base64ApiKey"`) +- `ApiKeyId` (when `AuthenticationType = "KeyIdAndKey"`) - `CertificateFingerprint` +Use `EnvironmentType = "SelfManaged"` with `Url` for self-managed clusters or endpoint-based hosted deployments. Use `EnvironmentType = "CloudHosted"` with `CloudId` for Elastic Cloud hosted deployments. Older records that only store `CloudId` are still inferred as cloud-hosted. Elastic Cloud connections require one of the authenticated modes. + This is different from the Elasticsearch knowledge-base backend configuration. The backend settings under `CrestApps:Search:Elasticsearch` define where the embedded knowledge-base chunks are written. The source mapping settings define where the raw source documents are read from. Because the remote source index is externally managed, document changes must be pushed into the sync pipeline through `IAIDataSourceChangeNotifier`. See [Custom Sources](./custom-sources.md) for the notification pattern. @@ -152,8 +169,13 @@ Then configure your `appsettings.Development.json`: "Search": { "Elasticsearch": { "Url": "https://my-cluster.es.us-east-1.aws.found.io:9243", + "CloudId": "", + "AuthenticationType": "Basic", "Username": "elastic", "Password": "your-secure-password", + "ApiKey": "", + "Base64ApiKey": "", + "ApiKeyId": "", "CertificateFingerprint": "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" } } @@ -165,9 +187,14 @@ Then configure your `appsettings.Development.json`: | Property | Type | Required | Default | Description | |----------|------|----------|---------|-------------| -| `Url` | `string` | Yes | — | Elasticsearch endpoint URL. Include the port if non-standard (e.g., `https://localhost:9200`). | +| `Url` | `string` | Conditionally | — | Elasticsearch endpoint URL. Include the port if non-standard (e.g., `https://localhost:9200`). Required when `CloudId` is empty. | +| `CloudId` | `string` | Conditionally | — | Elastic Cloud deployment identifier. Required when `Url` is empty. | +| `AuthenticationType` | `string` | No | `None` | Selects `None`, `Basic`, `ApiKey`, `Base64ApiKey`, or `KeyIdAndKey`. Elastic Cloud requires one of the authenticated modes. | | `Username` | `string` | No | — | Username for basic authentication. Typically `"elastic"` for the built-in superuser. | | `Password` | `string` | No | — | Password for basic authentication. | +| `ApiKey` | `string` | No | — | Raw API key for `ApiKey` auth, or the key portion for `KeyIdAndKey`. | +| `Base64ApiKey` | `string` | No | — | Base64-encoded API key value for `Base64ApiKey` auth. | +| `ApiKeyId` | `string` | No | — | API key identifier for `KeyIdAndKey` auth. | | `CertificateFingerprint` | `string` | No | — | SHA-256 fingerprint of the Elasticsearch TLS certificate. Required when using self-signed certificates. Format: `AA:BB:CC:...` | :::info @@ -250,10 +277,10 @@ Deleting an index removes all indexed documents permanently. Re-indexing from th **Error:** `Elasticsearch.Net.ElasticsearchClientException: 401 Unauthorized` -**Cause:** Invalid username or password. +**Cause:** Invalid credentials for the selected authentication type. **Fix:** -- Verify credentials in `appsettings.json` +- Verify the selected `AuthenticationType` and its matching credentials in `appsettings.json` - Reset the elastic user password: `docker exec -it elasticsearch bin/elasticsearch-reset-password -u elastic` ### Certificate Error diff --git a/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs index b112ad0f..fffb0a84 100644 --- a/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Azure.AISearch/ServiceCollectionExtensions.cs @@ -32,8 +32,8 @@ public static IServiceCollection AddCoreAzureAISearchAIDocumentSource(this IServ return services.AddCoreAzureAISearchSource(IndexProfileTypes.AIDocuments, descriptor => { - descriptor.DisplayName = new LocalizedString("AI Documents", "AI Documents"); - descriptor.Description = new LocalizedString("Azure AI Search AI Documents Description", "Create an Azure AI Search index for uploaded and embedded AI document chunks."); + descriptor.DisplayName = "AI Documents"; + descriptor.Description = "Create an Azure AI Search index for uploaded and embedded AI document chunks."; }).AddCoreAIDocumentIndexProfileHandler(); } @@ -47,8 +47,8 @@ public static IServiceCollection AddCoreAzureAISearchAIDataSource(this IServiceC return services.AddCoreAzureAISearchSource(IndexProfileTypes.DataSource, descriptor => { - descriptor.DisplayName = new LocalizedString("Data Source", "Data Source"); - descriptor.Description = new LocalizedString("Azure AI Search Data Source Description", "Create an Azure AI Search index for AI knowledge base data source documents."); + descriptor.DisplayName = "Data Source"; + descriptor.Description = "Create an Azure AI Search index for AI knowledge base data source documents."; }).AddCoreAIDataSourceRag() .Configure(options => options.AddOrUpdate( AIDataSourceSourceTypes.AzureAISearch, @@ -74,8 +74,8 @@ public static IServiceCollection AddCoreAzureAISearchAIMemorySource(this IServic return services.AddCoreAzureAISearchSource(IndexProfileTypes.AIMemory, descriptor => { - descriptor.DisplayName = new LocalizedString("AI Memory", "AI Memory"); - descriptor.Description = new LocalizedString("Azure AI Search AI Memory Description", "Create an Azure AI Search index for user and system memory records."); + descriptor.DisplayName = "AI Memory"; + descriptor.Description = "Create an Azure AI Search index for user and system memory records."; }).AddCoreAIMemoryIndexProfileHandler(); } diff --git a/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs index 26821d8f..e97e2f38 100644 --- a/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Elasticsearch/ServiceCollectionExtensions.cs @@ -33,8 +33,8 @@ public static IServiceCollection AddCoreElasticsearchAIDocumentSource(this IServ return services.AddCoreElasticsearchSource(IndexProfileTypes.AIDocuments, descriptor => { - descriptor.DisplayName = new LocalizedString("AI Documents", "AI Documents"); - descriptor.Description = new LocalizedString("Elasticsearch AI Documents Description", "Create an Elasticsearch index for uploaded and embedded AI document chunks."); + descriptor.DisplayName = "AI Documents"; + descriptor.Description = "Create an Elasticsearch index for uploaded and embedded AI document chunks."; }).AddCoreAIDocumentIndexProfileHandler(); } @@ -48,8 +48,8 @@ public static IServiceCollection AddCoreElasticsearchAIDataSource(this IServiceC return services.AddCoreElasticsearchSource(IndexProfileTypes.DataSource, descriptor => { - descriptor.DisplayName = new LocalizedString("Data Source", "Data Source"); - descriptor.Description = new LocalizedString("Elasticsearch Data Source Description", "Create an Elasticsearch index for AI knowledge base data source documents."); + descriptor.DisplayName = "Data Source"; + descriptor.Description = "Create an Elasticsearch index for AI knowledge base data source documents."; }).AddCoreAIDataSourceRag() .Configure(options => options.AddOrUpdate( AIDataSourceSourceTypes.Elasticsearch, @@ -76,8 +76,8 @@ public static IServiceCollection AddCoreElasticsearchAIMemorySource(this IServic return services.AddCoreElasticsearchSource(IndexProfileTypes.AIMemory, descriptor => { - descriptor.DisplayName = new LocalizedString("AI Memory", "AI Memory"); - descriptor.Description = new LocalizedString("Elasticsearch AI Memory Description", "Create an Elasticsearch index for user and system memory records."); + descriptor.DisplayName = "AI Memory"; + descriptor.Description = "Create an Elasticsearch index for user and system memory records."; }).AddCoreAIMemoryIndexProfileHandler(); } diff --git a/src/Primitives/CrestApps.Core.AI.Elasticsearch/Services/ElasticsearchAIDataSourceSourceHandler.cs b/src/Primitives/CrestApps.Core.AI.Elasticsearch/Services/ElasticsearchAIDataSourceSourceHandler.cs index c4d16ac5..5191dbbf 100644 --- a/src/Primitives/CrestApps.Core.AI.Elasticsearch/Services/ElasticsearchAIDataSourceSourceHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Elasticsearch/Services/ElasticsearchAIDataSourceSourceHandler.cs @@ -58,9 +58,18 @@ public ValueTask ValidateAsync(AIDataSource dataSource, ValidationResultDetails return ValueTask.CompletedTask; } - if (string.IsNullOrWhiteSpace(metadata.Url)) + var environmentType = metadata.GetEnvironmentType(); + + if (string.Equals(environmentType, ElasticsearchSourceMetadata.CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(metadata.CloudId)) + { + result.Fail(new ValidationResult("Elastic Cloud ID is required for cloud-hosted Elasticsearch deployments.", [nameof(ElasticsearchSourceMetadata.EnvironmentType), nameof(ElasticsearchSourceMetadata.CloudId)])); + } + } + else if (string.IsNullOrWhiteSpace(metadata.Url)) { - result.Fail(new ValidationResult("Elasticsearch URL is required.", [nameof(ElasticsearchSourceMetadata.Url)])); + result.Fail(new ValidationResult("Elasticsearch URL is required for self-managed deployments.", [nameof(ElasticsearchSourceMetadata.EnvironmentType), nameof(ElasticsearchSourceMetadata.Url)])); } if (string.IsNullOrWhiteSpace(metadata.IndexName)) @@ -71,12 +80,37 @@ public ValueTask ValidateAsync(AIDataSource dataSource, ValidationResultDetails var authenticationType = metadata.GetAuthenticationType(); var hasUsername = !string.IsNullOrWhiteSpace(metadata.Username); var hasPassword = !string.IsNullOrWhiteSpace(metadata.Password); + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase) && hasUsername != hasPassword) { result.Fail(new ValidationResult("Elasticsearch basic authentication requires both username and password.", [nameof(ElasticsearchSourceMetadata.Username), nameof(ElasticsearchSourceMetadata.Password)])); } + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(metadata.ApiKey)) + { + result.Fail(new ValidationResult("Elasticsearch API key authentication requires an API key.", [nameof(ElasticsearchSourceMetadata.ApiKey)])); + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(metadata.Base64ApiKey)) + { + result.Fail(new ValidationResult("Elasticsearch base64 API key authentication requires a base64-encoded API key.", [nameof(ElasticsearchSourceMetadata.Base64ApiKey)])); + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) && + (string.IsNullOrWhiteSpace(metadata.ApiKeyId) || string.IsNullOrWhiteSpace(metadata.ApiKey))) + { + result.Fail(new ValidationResult("Elasticsearch key ID and key authentication requires both an API key ID and API key.", [nameof(ElasticsearchSourceMetadata.ApiKeyId), nameof(ElasticsearchSourceMetadata.ApiKey)])); + } + + if (string.Equals(environmentType, ElasticsearchSourceMetadata.CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase) && + string.Equals(authenticationType, ElasticsearchSourceMetadata.NoneAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + result.Fail(new ValidationResult("Elastic Cloud connections require an authentication type and matching credentials.", [nameof(ElasticsearchSourceMetadata.EnvironmentType), nameof(ElasticsearchSourceMetadata.AuthenticationType), nameof(ElasticsearchSourceMetadata.CloudId)])); + } + return ValueTask.CompletedTask; } @@ -200,17 +234,34 @@ private static KeyValuePair CreateDocumentPair(AIDataSou throw new InvalidOperationException("Elasticsearch source metadata is missing."); } + var environmentType = metadata.GetEnvironmentType(); var authenticationType = metadata.GetAuthenticationType(); var protector = _dataProtectionProvider.CreateProtector(AIDataSourceProtectionConstants.SourceSecretPurpose); var password = string.Equals(authenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase) ? DataProtectionHelper.Unprotect(protector, metadata.Password, _logger, "Failed to unprotect AI data source field '{FieldName}' for data source '{DataSourceId}'.", nameof(ElasticsearchSourceMetadata.Password), dataSource.ItemId) : null; + var apiKey = string.Equals(authenticationType, ElasticsearchSourceMetadata.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) || + string.Equals(authenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) + ? DataProtectionHelper.Unprotect(protector, metadata.ApiKey, _logger, "Failed to unprotect AI data source field '{FieldName}' for data source '{DataSourceId}'.", nameof(ElasticsearchSourceMetadata.ApiKey), dataSource.ItemId) + : null; + var base64ApiKey = string.Equals(authenticationType, ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) + ? DataProtectionHelper.Unprotect(protector, metadata.Base64ApiKey, _logger, "Failed to unprotect AI data source field '{FieldName}' for data source '{DataSourceId}'.", nameof(ElasticsearchSourceMetadata.Base64ApiKey), dataSource.ItemId) + : null; var client = _clientFactory.Create(new ElasticsearchConnectionOptions { - Url = metadata.Url, + Url = string.Equals(environmentType, ElasticsearchSourceMetadata.CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase) + ? null + : metadata.Url, + CloudId = string.Equals(environmentType, ElasticsearchSourceMetadata.CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase) + ? metadata.CloudId + : null, + AuthenticationType = authenticationType, Username = string.Equals(authenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase) ? metadata.Username : null, Password = password, + ApiKeyId = string.Equals(authenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase) ? metadata.ApiKeyId : null, + ApiKey = apiKey, + Base64ApiKey = base64ApiKey, CertificateFingerprint = metadata.CertificateFingerprint, }); diff --git a/src/Primitives/CrestApps.Core.AI.PostgreSQL/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.PostgreSQL/ServiceCollectionExtensions.cs index 263a7299..6a5cb4cc 100644 --- a/src/Primitives/CrestApps.Core.AI.PostgreSQL/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.PostgreSQL/ServiceCollectionExtensions.cs @@ -33,8 +33,8 @@ public static IServiceCollection AddCorePostgreSQLAIDocumentSource(this IService return services.AddCorePostgreSQLSource(IndexProfileTypes.AIDocuments, descriptor => { - descriptor.DisplayName = new LocalizedString("AI Documents", "AI Documents"); - descriptor.Description = new LocalizedString("PostgreSQL AI Documents Description", "Create a PostgreSQL index for uploaded and embedded AI document chunks."); + descriptor.DisplayName = "AI Documents"; + descriptor.Description = "Create a PostgreSQL index for uploaded and embedded AI document chunks."; }).AddCoreAIDocumentIndexProfileHandler(); } @@ -48,8 +48,8 @@ public static IServiceCollection AddCorePostgreSQLAIDataSource(this IServiceColl return services.AddCorePostgreSQLSource(IndexProfileTypes.DataSource, descriptor => { - descriptor.DisplayName = new LocalizedString("Data Source", "Data Source"); - descriptor.Description = new LocalizedString("PostgreSQL Data Source Description", "Create a PostgreSQL index for AI knowledge base data source documents."); + descriptor.DisplayName = "Data Source"; + descriptor.Description = "Create a PostgreSQL index for AI knowledge base data source documents."; }).AddCoreAIDataSourceRag() .Configure(options => options.AddOrUpdate( AIDataSourceSourceTypes.PostgreSQL, @@ -76,8 +76,8 @@ public static IServiceCollection AddCorePostgreSQLAIMemorySource(this IServiceCo return services.AddCorePostgreSQLSource(IndexProfileTypes.AIMemory, descriptor => { - descriptor.DisplayName = new LocalizedString("AI Memory", "AI Memory"); - descriptor.Description = new LocalizedString("PostgreSQL AI Memory Description", "Create a PostgreSQL index for user and system memory records."); + descriptor.DisplayName = "AI Memory"; + descriptor.Description = "Create a PostgreSQL index for user and system memory records."; }).AddCoreAIMemoryIndexProfileHandler(); } diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/CrestApps.Core.Elasticsearch.csproj b/src/Primitives/CrestApps.Core.Elasticsearch/CrestApps.Core.Elasticsearch.csproj index a0d8d563..96122d22 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/CrestApps.Core.Elasticsearch.csproj +++ b/src/Primitives/CrestApps.Core.Elasticsearch/CrestApps.Core.Elasticsearch.csproj @@ -17,6 +17,11 @@ + + + + + diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/ElasticsearchConnectionOptions.cs b/src/Primitives/CrestApps.Core.Elasticsearch/ElasticsearchConnectionOptions.cs index fa8d36ea..87d19f43 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/ElasticsearchConnectionOptions.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/ElasticsearchConnectionOptions.cs @@ -6,11 +6,46 @@ namespace CrestApps.Core.Elasticsearch; /// public sealed class ElasticsearchConnectionOptions { + /// + /// The anonymous Elasticsearch authentication type value. + /// + public const string NoneAuthenticationType = "None"; + + /// + /// The basic Elasticsearch authentication type value. + /// + public const string BasicAuthenticationType = "Basic"; + + /// + /// The Elasticsearch API key authentication type value. + /// + public const string ApiKeyAuthenticationType = "ApiKey"; + + /// + /// The Elasticsearch base64-encoded API key authentication type value. + /// + public const string Base64ApiKeyAuthenticationType = "Base64ApiKey"; + + /// + /// The Elasticsearch key identifier plus API key authentication type value. + /// + public const string KeyIdAndKeyAuthenticationType = "KeyIdAndKey"; + /// /// The Elasticsearch server URL (e.g. "https://localhost:9200"). /// public string Url { get; set; } + /// + /// The optional Elastic Cloud deployment identifier. + /// + public string CloudId { get; set; } + + /// + /// The authentication type. + /// + public string AuthenticationType { get; set; } + /// /// Optional username for basic authentication. /// @@ -21,6 +56,21 @@ public sealed class ElasticsearchConnectionOptions /// public string Password { get; set; } + /// + /// Optional API key value for Elasticsearch API key authentication. + /// + public string ApiKey { get; set; } + + /// + /// Optional base64-encoded API key value for Elasticsearch API key authentication. + /// + public string Base64ApiKey { get; set; } + + /// + /// Optional API key identifier for Elasticsearch API key authentication. + /// + public string ApiKeyId { get; set; } + /// /// Optional certificate fingerprint for TLS verification. /// @@ -30,4 +80,56 @@ public sealed class ElasticsearchConnectionOptions /// Optional prefix applied to MVC-managed remote index names. /// public string IndexPrefix { get; set; } + + /// + /// Gets the normalized authentication type. + /// + public string GetAuthenticationType() + { + if (string.IsNullOrWhiteSpace(AuthenticationType)) + { + if (!string.IsNullOrWhiteSpace(Username) || !string.IsNullOrWhiteSpace(Password)) + { + return BasicAuthenticationType; + } + + if (!string.IsNullOrWhiteSpace(ApiKeyId) || !string.IsNullOrWhiteSpace(ApiKey)) + { + return string.IsNullOrWhiteSpace(ApiKeyId) + ? ApiKeyAuthenticationType + : KeyIdAndKeyAuthenticationType; + } + + if (!string.IsNullOrWhiteSpace(Base64ApiKey)) + { + return Base64ApiKeyAuthenticationType; + } + + return NoneAuthenticationType; + } + + var authenticationType = AuthenticationType.Trim(); + + if (string.Equals(authenticationType, BasicAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return BasicAuthenticationType; + } + + if (string.Equals(authenticationType, ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return Base64ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return KeyIdAndKeyAuthenticationType; + } + + return NoneAuthenticationType; + } } diff --git a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs index 2435983c..8c35ebd6 100644 --- a/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs +++ b/src/Primitives/CrestApps.Core.Elasticsearch/Services/ElasticsearchClientFactory.cs @@ -1,7 +1,10 @@ using Elastic.Clients.Elasticsearch; using Elastic.Transport; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Options; +using Polly; +using System.Net.Http; namespace CrestApps.Core.Elasticsearch.Services; @@ -10,6 +13,7 @@ namespace CrestApps.Core.Elasticsearch.Services; /// public sealed class ElasticsearchClientFactory : IElasticsearchClientFactory { + private static readonly ResiliencePipeline HttpResiliencePipeline = CreateHttpResiliencePipeline(); private readonly ILogger _logger; private readonly ElasticsearchConnectionOptions _options; private readonly object _syncLock = new(); @@ -55,28 +59,50 @@ public ElasticsearchClient Create(ElasticsearchConnectionOptions configuration) { ArgumentNullException.ThrowIfNull(configuration); - if (string.IsNullOrWhiteSpace(configuration.Url)) + var authenticationType = configuration.GetAuthenticationType(); + var hasCloudId = !string.IsNullOrWhiteSpace(configuration.CloudId); + var hasUrl = !string.IsNullOrWhiteSpace(configuration.Url); + + if (!hasCloudId && !hasUrl) { - throw new InvalidOperationException("Elasticsearch is not configured."); + throw new InvalidOperationException("Elasticsearch is not configured. Set either the URL or the Cloud ID."); } - if (!Uri.TryCreate(configuration.Url.Trim(), UriKind.Absolute, out var endpoint)) + AuthorizationHeader authorizationHeader = null; + if (!string.Equals(authenticationType, ElasticsearchConnectionOptions.NoneAuthenticationType, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException("The Elasticsearch URL is invalid."); + authorizationHeader = CreateAuthorizationHeader(configuration, authenticationType); } - var settings = new ElasticsearchClientSettings(endpoint); - var hasUsername = !string.IsNullOrWhiteSpace(configuration.Username); - var hasPassword = !string.IsNullOrWhiteSpace(configuration.Password); + if (hasCloudId && authorizationHeader == null) + { + throw new InvalidOperationException("Elastic Cloud connections require an authentication type and matching credentials."); + } - if (hasUsername != hasPassword) + ElasticsearchClientSettings settings; + object connectionTarget; + + if (hasCloudId) + { + settings = new ElasticsearchClientSettings( + new CloudNodePool(configuration.CloudId.Trim(), authorizationHeader ?? throw new InvalidOperationException("Elastic Cloud connections require an authentication type and matching credentials.")), + CreateRequestInvoker()); + connectionTarget = "Elastic Cloud"; + } + else { - throw new InvalidOperationException("Elasticsearch basic authentication requires both username and password."); + if (!Uri.TryCreate(configuration.Url.Trim(), UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException("The Elasticsearch URL is invalid."); + } + + settings = new ElasticsearchClientSettings(new SingleNodePool(endpoint), CreateRequestInvoker()); + connectionTarget = endpoint; } - if (hasUsername) + if (authorizationHeader != null) { - settings.Authentication(new BasicAuthentication(configuration.Username, configuration.Password)); + settings.Authentication(authorizationHeader); } if (!string.IsNullOrWhiteSpace(configuration.CertificateFingerprint)) @@ -87,11 +113,87 @@ public ElasticsearchClient Create(ElasticsearchConnectionOptions configuration) if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Creating Elasticsearch client for endpoint '{Endpoint}' with authentication configured: {HasAuthentication}.", - endpoint, - hasUsername); + "Creating Elasticsearch client for target '{Target}' with authentication type '{AuthenticationType}'.", + connectionTarget, + authenticationType); } return new ElasticsearchClient(settings); } + + internal static AuthorizationHeader CreateAuthorizationHeader(ElasticsearchConnectionOptions configuration, string authenticationType) + { + if (string.Equals(authenticationType, ElasticsearchConnectionOptions.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + var hasUsername = !string.IsNullOrWhiteSpace(configuration.Username); + var hasPassword = !string.IsNullOrWhiteSpace(configuration.Password); + + if (hasUsername != hasPassword) + { + throw new InvalidOperationException("Elasticsearch basic authentication requires both username and password."); + } + + return hasUsername + ? new BasicAuthentication(configuration.Username, configuration.Password) + : throw new InvalidOperationException("Elasticsearch basic authentication requires both username and password."); + } + + if (string.Equals(authenticationType, ElasticsearchConnectionOptions.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return !string.IsNullOrWhiteSpace(configuration.ApiKey) + ? new ApiKey(configuration.ApiKey) + : throw new InvalidOperationException("Elasticsearch API key authentication requires an API key."); + } + + if (string.Equals(authenticationType, ElasticsearchConnectionOptions.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return !string.IsNullOrWhiteSpace(configuration.Base64ApiKey) + ? new Base64ApiKey(configuration.Base64ApiKey) + : throw new InvalidOperationException("Elasticsearch base64 API key authentication requires a base64-encoded API key."); + } + + if (string.Equals(authenticationType, ElasticsearchConnectionOptions.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + var hasApiKeyId = !string.IsNullOrWhiteSpace(configuration.ApiKeyId); + var hasApiKey = !string.IsNullOrWhiteSpace(configuration.ApiKey); + + if (!hasApiKeyId || !hasApiKey) + { + throw new InvalidOperationException("Elasticsearch key ID and key authentication requires both an API key ID and API key."); + } + + return new Base64ApiKey(configuration.ApiKeyId, configuration.ApiKey); + } + + throw new InvalidOperationException($"Unsupported Elasticsearch authentication type '{authenticationType}'."); + } + + internal static IRequestInvoker CreateRequestInvoker() + { + return new HttpRequestInvoker(CreateResilientHttpMessageHandler); + } + + internal static HttpMessageHandler CreateResilientHttpMessageHandler(HttpMessageHandler innerHandler, BoundConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(innerHandler); + ArgumentNullException.ThrowIfNull(configuration); + + return new ResilienceHandler(HttpResiliencePipeline) + { + InnerHandler = innerHandler, + }; + } + + private static ResiliencePipeline CreateHttpResiliencePipeline() + { + var options = new HttpStandardResilienceOptions(); + + return new ResiliencePipelineBuilder() + .AddRateLimiter(options.RateLimiter) + .AddTimeout(options.TotalRequestTimeout) + .AddRetry(options.Retry) + .AddCircuitBreaker(options.CircuitBreaker) + .AddTimeout(options.AttemptTimeout) + .Build(); + } } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Create.razor index 4b3b79bf..4c919532 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Create.razor @@ -78,8 +78,33 @@
- - + + + + + +
+ + @if (UsesElasticsearchSelfManagedEnvironment()) + { +
+ + +
Required for self-managed clusters or endpoint-based hosted deployments.
+
+ } + else + { +
+ + +
Required for Elastic Cloud hosted deployments.
+
+ } + +
+ +
@@ -87,14 +112,12 @@ + + +
-
- - -
- @if (UsesElasticsearchBasicAuthentication()) {
@@ -108,6 +131,35 @@
} + @if (UsesElasticsearchApiKeyAuthentication()) + { +
+ + +
+ } + + @if (UsesElasticsearchBase64ApiKeyAuthentication()) + { +
+ + +
+ } + + @if (UsesElasticsearchKeyIdAndKeyAuthentication()) + { +
+ + +
+ +
+ + +
+ } +
@@ -127,6 +179,11 @@
+
+ + +
+
@@ -136,11 +193,6 @@
-
- - -
- @if (UsesAzureAISearchApiKeyAuthentication()) {
@@ -167,13 +219,13 @@
- - + +
- - + +
} @@ -224,7 +276,10 @@ @code { - private AIDataSourceViewModel _model = new(); + private AIDataSourceViewModel _model = new() + { + SourceType = string.Empty, + }; private List _errors = []; private List> _sourceTypes = []; private List> _sourceIndexProfiles = []; @@ -337,9 +392,21 @@ private bool IsElasticsearchSource() => string.Equals(_model.SourceType, AIDataSourceSourceTypes.Elasticsearch, StringComparison.OrdinalIgnoreCase); + private bool UsesElasticsearchSelfManagedEnvironment() + => string.Equals(_model.ElasticsearchEnvironmentType, ElasticsearchSourceMetadata.SelfManagedEnvironmentType, StringComparison.OrdinalIgnoreCase); + private bool UsesElasticsearchBasicAuthentication() => string.Equals(_model.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase); + private bool UsesElasticsearchApiKeyAuthentication() + => string.Equals(_model.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + + private bool UsesElasticsearchBase64ApiKeyAuthentication() + => string.Equals(_model.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + + private bool UsesElasticsearchKeyIdAndKeyAuthentication() + => string.Equals(_model.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + private bool IsAzureAISearchSource() => string.Equals(_model.SourceType, AIDataSourceSourceTypes.AzureAISearch, StringComparison.OrdinalIgnoreCase); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Edit.razor index 451e898c..87023f30 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/DataSources/AIDataSources/Edit.razor @@ -87,8 +87,33 @@ else
- - + + + + + +
+ + @if (UsesElasticsearchSelfManagedEnvironment()) + { +
+ + +
Required for self-managed clusters or endpoint-based hosted deployments.
+
+ } + else + { +
+ + +
Required for Elastic Cloud hosted deployments.
+
+ } + +
+ +
@@ -96,14 +121,12 @@ else + + +
-
- - -
- @if (UsesElasticsearchBasicAuthentication()) {
@@ -117,6 +140,35 @@ else
} + @if (UsesElasticsearchApiKeyAuthentication()) + { +
+ + +
+ } + + @if (UsesElasticsearchBase64ApiKeyAuthentication()) + { +
+ + +
+ } + + @if (UsesElasticsearchKeyIdAndKeyAuthentication()) + { +
+ + +
+ +
+ + +
+ } +
@@ -136,6 +188,11 @@ else
+
+ + +
+
@@ -145,11 +202,6 @@ else
-
- - -
- @if (UsesAzureAISearchApiKeyAuthentication()) {
@@ -176,13 +228,13 @@ else
- - + +
- - + +
} @@ -368,9 +420,21 @@ else private bool IsElasticsearchSource() => string.Equals(_model?.SourceType, AIDataSourceSourceTypes.Elasticsearch, StringComparison.OrdinalIgnoreCase); + private bool UsesElasticsearchSelfManagedEnvironment() + => string.Equals(_model?.ElasticsearchEnvironmentType, ElasticsearchSourceMetadata.SelfManagedEnvironmentType, StringComparison.OrdinalIgnoreCase); + private bool UsesElasticsearchBasicAuthentication() => string.Equals(_model?.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase); + private bool UsesElasticsearchApiKeyAuthentication() + => string.Equals(_model?.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + + private bool UsesElasticsearchBase64ApiKeyAuthentication() + => string.Equals(_model?.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + + private bool UsesElasticsearchKeyIdAndKeyAuthentication() + => string.Equals(_model?.ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase); + private bool IsAzureAISearchSource() => string.Equals(_model?.SourceType, AIDataSourceSourceTypes.AzureAISearch, StringComparison.OrdinalIgnoreCase); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Create.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Create.razor index 67f44440..24c186a8 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Create.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Create.razor @@ -108,15 +108,15 @@ protected override async Task OnInitializedAsync() { _sources = SourceOptions.Value.Sources - .OrderBy(s => s.ProviderDisplayName.Value, StringComparer.OrdinalIgnoreCase) - .ThenBy(s => s.DisplayName.Value, StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s.ProviderDisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase) .ToArray(); _model.Sources = _sources; _model.Providers = _sources .GroupBy(s => s.ProviderName, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) - .Select(s => new KeyValuePair(s.ProviderName, s.ProviderDisplayName.Value)) + .Select(s => new KeyValuePair(s.ProviderName, s.ProviderDisplayName)) .ToList(); SyncFilteredTypes(); @@ -136,7 +136,7 @@ _filteredTypes = matches .GroupBy(s => s.Type, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) - .Select(s => new KeyValuePair(s.Type, s.DisplayName.Value)) + .Select(s => new KeyValuePair(s.Type, s.DisplayName)) .ToList(); } diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor index ba13c819..f443c1ac 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor +++ b/src/Startup/CrestApps.Core.Blazor.Web/Components/Pages/Indexing/IndexProfiles/Edit.razor @@ -190,21 +190,21 @@ else private async Task PopulateDropdownsAsync() { var sources = SourceOptions.Value.Sources - .OrderBy(s => s.ProviderDisplayName.Value, StringComparer.OrdinalIgnoreCase) - .ThenBy(s => s.DisplayName.Value, StringComparer.OrdinalIgnoreCase) + .OrderBy(s => s.ProviderDisplayName, StringComparer.OrdinalIgnoreCase) + .ThenBy(s => s.DisplayName, StringComparer.OrdinalIgnoreCase) .ToArray(); _model.Sources = sources; _model.Providers = sources .GroupBy(s => s.ProviderName, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) - .Select(s => new KeyValuePair(s.ProviderName, s.ProviderDisplayName.Value)) + .Select(s => new KeyValuePair(s.ProviderName, s.ProviderDisplayName)) .ToList(); _model.Types = sources .GroupBy(s => s.Type, StringComparer.OrdinalIgnoreCase) .Select(g => g.First()) - .Select(s => new KeyValuePair(s.Type, s.DisplayName.Value)) + .Select(s => new KeyValuePair(s.Type, s.DisplayName)) .ToList(); _model.EmbeddingDeployments = (await DeploymentManager.GetByPurposeAsync(AIDeploymentPurpose.Embedding)) diff --git a/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs b/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs index d09c5595..9f1bbb01 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/Services/EntityCoreSampleServiceCollectionExtensions.cs @@ -6,10 +6,12 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Services; +using CrestApps.Core.Azure.AISearch; using CrestApps.Core.Data.EntityCore; using CrestApps.Core.Data.EntityCore.Services; using CrestApps.Core.Elasticsearch; using CrestApps.Core.Infrastructure.Indexing; +using CrestApps.Core.PostgreSQL; using CrestApps.Core.Services; using CrestApps.Core.Startup.Shared.Areas.AI.Handlers; using CrestApps.Core.Startup.Shared.Areas.AIChat.Services; @@ -61,15 +63,22 @@ public static IServiceCollection AddBlazorSampleHostServices(this IServiceCollec services.Configure(options => options .AddOrUpdate(ElasticsearchConstants.ProviderName, new LocalizedString("Elasticsearch", "Elasticsearch"), IndexProfileTypes.Articles, descriptor => { - descriptor.DisplayName = new LocalizedString("Articles", "Articles"); - descriptor.Description = new LocalizedString("Blazor Elasticsearch Articles Description", "Create an Elasticsearch index for sample article records managed in the Blazor app."); + descriptor.DisplayName = "Articles"; + descriptor.Description = "Create an Elasticsearch index for sample article records managed in the Blazor app."; }) ); services.Configure(options => options - .AddOrUpdate(ElasticsearchConstants.ProviderName, new LocalizedString("Azure AI Search", "Azure AI Search"), IndexProfileTypes.Articles, descriptor => + .AddOrUpdate(AISearchConstants.ProviderName, new LocalizedString("Azure AI Search", "Azure AI Search"), IndexProfileTypes.Articles, descriptor => { - descriptor.DisplayName = new LocalizedString("Articles", "Articles"); - descriptor.Description = new LocalizedString("Blazor Azure AI Search Articles Description", "Create an Azure AI Search index for sample article records managed in the Blazor app."); + descriptor.DisplayName = "Articles"; + descriptor.Description = "Create an Azure AI Search index for sample article records managed in the Blazor app."; + }) + ); + services.Configure(options => options + .AddOrUpdate(PostgreSQLConstants.ProviderName, new LocalizedString("PostgreSQL", "PostgreSQL"), IndexProfileTypes.Articles, descriptor => + { + descriptor.DisplayName = "Articles"; + descriptor.Description = "Create a PostgreSQL index for sample article records managed in the Blazor app."; }) ); diff --git a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDataSourceViewModel.cs b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDataSourceViewModel.cs index 811969d7..5ad88411 100644 --- a/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDataSourceViewModel.cs +++ b/src/Startup/CrestApps.Core.Blazor.Web/ViewModels/AIDataSourceViewModel.cs @@ -23,6 +23,10 @@ public sealed class AIDataSourceViewModel public string ElasticsearchUrl { get; set; } + public string ElasticsearchCloudId { get; set; } + + public string ElasticsearchEnvironmentType { get; set; } = ElasticsearchSourceMetadata.SelfManagedEnvironmentType; + public string ElasticsearchAuthenticationType { get; set; } = ElasticsearchSourceMetadata.NoneAuthenticationType; public string ElasticsearchIndexName { get; set; } @@ -31,6 +35,12 @@ public sealed class AIDataSourceViewModel public string ElasticsearchPassword { get; set; } + public string ElasticsearchApiKey { get; set; } + + public string ElasticsearchBase64ApiKey { get; set; } + + public string ElasticsearchApiKeyId { get; set; } + public string ElasticsearchCertificateFingerprint { get; set; } public string AzureAISearchEndpoint { get; set; } @@ -63,10 +73,13 @@ public static AIDataSourceViewModel FromDataSource(AIDataSource ds) if (ds.TryGet(out var elasticsearch)) { + model.ElasticsearchEnvironmentType = elasticsearch.GetEnvironmentType(); model.ElasticsearchUrl = elasticsearch.Url; + model.ElasticsearchCloudId = elasticsearch.CloudId; model.ElasticsearchAuthenticationType = elasticsearch.GetAuthenticationType(); model.ElasticsearchIndexName = elasticsearch.IndexName; model.ElasticsearchUsername = elasticsearch.Username; + model.ElasticsearchApiKeyId = elasticsearch.ApiKeyId; model.ElasticsearchCertificateFingerprint = elasticsearch.CertificateFingerprint; } @@ -109,20 +122,28 @@ public void ApplyTo(AIDataSource ds, IDataProtector protector) if (string.Equals(ds.SourceType, AIDataSourceSourceTypes.Elasticsearch, StringComparison.OrdinalIgnoreCase)) { - var authenticationType = string.Equals(ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase) - ? ElasticsearchSourceMetadata.BasicAuthenticationType - : ElasticsearchSourceMetadata.NoneAuthenticationType; + var environmentType = NormalizeElasticsearchEnvironmentType(ElasticsearchEnvironmentType); + var authenticationType = NormalizeElasticsearchAuthenticationType(ElasticsearchAuthenticationType); + ds.Put(new ElasticsearchSourceMetadata { - Url = ElasticsearchUrl?.Trim(), + EnvironmentType = environmentType, + Url = environmentType == ElasticsearchSourceMetadata.SelfManagedEnvironmentType ? ElasticsearchUrl?.Trim() : null, + CloudId = environmentType == ElasticsearchSourceMetadata.CloudHostedEnvironmentType ? ElasticsearchCloudId?.Trim() : null, AuthenticationType = authenticationType, IndexName = ElasticsearchIndexName?.Trim(), Username = authenticationType == ElasticsearchSourceMetadata.BasicAuthenticationType ? ElasticsearchUsername?.Trim() : null, Password = authenticationType == ElasticsearchSourceMetadata.BasicAuthenticationType - ? string.IsNullOrWhiteSpace(ElasticsearchPassword) - ? existingElasticsearchMetadata?.Password - : protector.Protect(ElasticsearchPassword) + ? ProtectSecret(ElasticsearchPassword, existingElasticsearchMetadata?.Password, protector) + : null, + ApiKey = authenticationType == ElasticsearchSourceMetadata.ApiKeyAuthenticationType || + authenticationType == ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType + ? ProtectSecret(ElasticsearchApiKey, existingElasticsearchMetadata?.ApiKey, protector) + : null, + Base64ApiKey = authenticationType == ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType + ? ProtectSecret(ElasticsearchBase64ApiKey, existingElasticsearchMetadata?.Base64ApiKey, protector) : null, + ApiKeyId = authenticationType == ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType ? ElasticsearchApiKeyId?.Trim() : null, CertificateFingerprint = ElasticsearchCertificateFingerprint?.Trim(), }); } @@ -161,4 +182,46 @@ public void ApplyTo(AIDataSource ds, IDataProtector protector) }); } } + + private static string NormalizeElasticsearchEnvironmentType(string environmentType) + { + if (string.Equals(environmentType, ElasticsearchSourceMetadata.CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.CloudHostedEnvironmentType; + } + + return ElasticsearchSourceMetadata.SelfManagedEnvironmentType; + } + + private static string NormalizeElasticsearchAuthenticationType(string authenticationType) + { + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.BasicAuthenticationType; + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType; + } + + return ElasticsearchSourceMetadata.NoneAuthenticationType; + } + + private static string ProtectSecret(string value, string existingValue, IDataProtector protector) + { + return string.IsNullOrWhiteSpace(value) + ? existingValue + : protector.Protect(value); + } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Controllers/AIDataSourceController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Controllers/AIDataSourceController.cs index 50e875fc..06db38a2 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Controllers/AIDataSourceController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Controllers/AIDataSourceController.cs @@ -53,7 +53,11 @@ public async Task Index() public async Task Create() { - var model = new AIDataSourceViewModel(); + var model = new AIDataSourceViewModel + { + SourceType = string.Empty, + }; + await PopulateDropdownsAsync(model); return View(model); @@ -230,11 +234,16 @@ private static string MapValidationMemberName(string sourceType, string memberNa return memberName switch { nameof(ElasticsearchSourceMetadata) => nameof(AIDataSourceViewModel.SourceType), + nameof(ElasticsearchSourceMetadata.EnvironmentType) => nameof(AIDataSourceViewModel.ElasticsearchEnvironmentType), nameof(ElasticsearchSourceMetadata.Url) => nameof(AIDataSourceViewModel.ElasticsearchUrl), + nameof(ElasticsearchSourceMetadata.CloudId) => nameof(AIDataSourceViewModel.ElasticsearchCloudId), nameof(ElasticsearchSourceMetadata.AuthenticationType) => nameof(AIDataSourceViewModel.ElasticsearchAuthenticationType), nameof(ElasticsearchSourceMetadata.IndexName) => nameof(AIDataSourceViewModel.ElasticsearchIndexName), nameof(ElasticsearchSourceMetadata.Username) => nameof(AIDataSourceViewModel.ElasticsearchUsername), nameof(ElasticsearchSourceMetadata.Password) => nameof(AIDataSourceViewModel.ElasticsearchPassword), + nameof(ElasticsearchSourceMetadata.ApiKey) => nameof(AIDataSourceViewModel.ElasticsearchApiKey), + nameof(ElasticsearchSourceMetadata.Base64ApiKey) => nameof(AIDataSourceViewModel.ElasticsearchBase64ApiKey), + nameof(ElasticsearchSourceMetadata.ApiKeyId) => nameof(AIDataSourceViewModel.ElasticsearchApiKeyId), nameof(ElasticsearchSourceMetadata.CertificateFingerprint) => nameof(AIDataSourceViewModel.ElasticsearchCertificateFingerprint), _ => memberName, }; diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/ViewModels/AIDataSourceViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/ViewModels/AIDataSourceViewModel.cs index 92277548..bcb07a33 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/ViewModels/AIDataSourceViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/ViewModels/AIDataSourceViewModel.cs @@ -25,6 +25,10 @@ public sealed class AIDataSourceViewModel public string ElasticsearchUrl { get; set; } + public string ElasticsearchCloudId { get; set; } + + public string ElasticsearchEnvironmentType { get; set; } = ElasticsearchSourceMetadata.SelfManagedEnvironmentType; + public string ElasticsearchAuthenticationType { get; set; } = ElasticsearchSourceMetadata.NoneAuthenticationType; public string ElasticsearchIndexName { get; set; } @@ -33,6 +37,12 @@ public sealed class AIDataSourceViewModel public string ElasticsearchPassword { get; set; } + public string ElasticsearchApiKey { get; set; } + + public string ElasticsearchBase64ApiKey { get; set; } + + public string ElasticsearchApiKeyId { get; set; } + public string ElasticsearchCertificateFingerprint { get; set; } public string AzureAISearchEndpoint { get; set; } @@ -74,10 +84,13 @@ public static AIDataSourceViewModel FromDataSource(AIDataSource ds) if (ds.TryGet(out var elasticsearch)) { + model.ElasticsearchEnvironmentType = elasticsearch.GetEnvironmentType(); model.ElasticsearchUrl = elasticsearch.Url; + model.ElasticsearchCloudId = elasticsearch.CloudId; model.ElasticsearchAuthenticationType = elasticsearch.GetAuthenticationType(); model.ElasticsearchIndexName = elasticsearch.IndexName; model.ElasticsearchUsername = elasticsearch.Username; + model.ElasticsearchApiKeyId = elasticsearch.ApiKeyId; model.ElasticsearchCertificateFingerprint = elasticsearch.CertificateFingerprint; } @@ -120,20 +133,28 @@ public void ApplyTo(AIDataSource ds, IDataProtector protector) if (string.Equals(ds.SourceType, AIDataSourceSourceTypes.Elasticsearch, StringComparison.OrdinalIgnoreCase)) { - var authenticationType = string.Equals(ElasticsearchAuthenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase) - ? ElasticsearchSourceMetadata.BasicAuthenticationType - : ElasticsearchSourceMetadata.NoneAuthenticationType; + var environmentType = NormalizeElasticsearchEnvironmentType(ElasticsearchEnvironmentType); + var authenticationType = NormalizeElasticsearchAuthenticationType(ElasticsearchAuthenticationType); + ds.Put(new ElasticsearchSourceMetadata { - Url = ElasticsearchUrl?.Trim(), + EnvironmentType = environmentType, + Url = environmentType == ElasticsearchSourceMetadata.SelfManagedEnvironmentType ? ElasticsearchUrl?.Trim() : null, + CloudId = environmentType == ElasticsearchSourceMetadata.CloudHostedEnvironmentType ? ElasticsearchCloudId?.Trim() : null, AuthenticationType = authenticationType, IndexName = ElasticsearchIndexName?.Trim(), Username = authenticationType == ElasticsearchSourceMetadata.BasicAuthenticationType ? ElasticsearchUsername?.Trim() : null, Password = authenticationType == ElasticsearchSourceMetadata.BasicAuthenticationType - ? string.IsNullOrWhiteSpace(ElasticsearchPassword) - ? existingElasticsearchMetadata?.Password - : protector.Protect(ElasticsearchPassword) + ? ProtectSecret(ElasticsearchPassword, existingElasticsearchMetadata?.Password, protector) + : null, + ApiKey = authenticationType == ElasticsearchSourceMetadata.ApiKeyAuthenticationType || + authenticationType == ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType + ? ProtectSecret(ElasticsearchApiKey, existingElasticsearchMetadata?.ApiKey, protector) + : null, + Base64ApiKey = authenticationType == ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType + ? ProtectSecret(ElasticsearchBase64ApiKey, existingElasticsearchMetadata?.Base64ApiKey, protector) : null, + ApiKeyId = authenticationType == ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType ? ElasticsearchApiKeyId?.Trim() : null, CertificateFingerprint = ElasticsearchCertificateFingerprint?.Trim(), }); } @@ -172,4 +193,46 @@ public void ApplyTo(AIDataSource ds, IDataProtector protector) }); } } + + private static string NormalizeElasticsearchEnvironmentType(string environmentType) + { + if (string.Equals(environmentType, ElasticsearchSourceMetadata.CloudHostedEnvironmentType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.CloudHostedEnvironmentType; + } + + return ElasticsearchSourceMetadata.SelfManagedEnvironmentType; + } + + private static string NormalizeElasticsearchAuthenticationType(string authenticationType) + { + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.BasicAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.BasicAuthenticationType; + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.Base64ApiKeyAuthenticationType; + } + + if (string.Equals(authenticationType, ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType, StringComparison.OrdinalIgnoreCase)) + { + return ElasticsearchSourceMetadata.KeyIdAndKeyAuthenticationType; + } + + return ElasticsearchSourceMetadata.NoneAuthenticationType; + } + + private static string ProtectSecret(string value, string existingValue, IDataProtector protector) + { + return string.IsNullOrWhiteSpace(value) + ? existingValue + : protector.Protect(value); + } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Create.cshtml index e7f2cc5c..93298e0b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Create.cshtml @@ -48,9 +48,32 @@
+ + + +
+ +
+
Required for self-managed clusters or endpoint-based hosted deployments.
+
+ +
+ + + +
Required for Elastic Cloud hosted deployments.
+
+ +
+ + +
@@ -58,16 +81,13 @@
-
- - - -
-
@@ -82,6 +102,30 @@
+
+
+ + + +
+
+ +
+
+ + + +
+
+ +
+
+ + + +
+
+
@@ -102,6 +146,12 @@
+
+ + + +
+
- -
-
@@ -143,17 +187,17 @@ Configure the remote PostgreSQL table that CrestApps.Core should read from before chunking and embedding documents.
-
- - - -
-
+ +
+ + + +

diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Edit.cshtml index ef8de8e4..94b5cbb2 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/Edit.cshtml @@ -49,9 +49,32 @@
+ + + +
+ +
+
Required for self-managed clusters or endpoint-based hosted deployments.
+
+ +
+ + + +
Required for Elastic Cloud hosted deployments.
+
+ +
+ + +
@@ -59,16 +82,13 @@
-
- - - -
-
@@ -83,6 +103,30 @@
+
+
+ + + +
+
+ +
+
+ + + +
+
+ +
+
+ + + +
+
+
@@ -103,6 +147,12 @@
+
+ + + +
+
- -
-
@@ -144,17 +188,17 @@ Configure the remote PostgreSQL table that CrestApps.Core should read from before chunking and embedding documents.
-
- - - -
-
+ +
+ + + +

diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/_SourceSettingsScript.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/_SourceSettingsScript.cshtml index 8e675a51..bca8955b 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/_SourceSettingsScript.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/DataSources/Views/AIDataSource/_SourceSettingsScript.cshtml @@ -1,6 +1,7 @@