diff --git a/.github/workflows/publish_npm_preview.yml b/.github/workflows/publish_npm_preview.yml index f4f0bdb6..e6fcae54 100644 --- a/.github/workflows/publish_npm_preview.yml +++ b/.github/workflows/publish_npm_preview.yml @@ -16,6 +16,8 @@ jobs: name: Publish @crestapps/ai-chat-ui (preview) steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 - name: Check if should publish id: check-publish @@ -57,7 +59,36 @@ jobs: Write-Output "Current SHA: ${{ github.sha }}" Write-Output "New commits since last preview publish: $hasNewCommitSinceLastRelease" - $shouldPublish = $eventName -eq 'schedule' -and $hasNewCommitSinceLastRelease + if (-not $hasNewCommitSinceLastRelease) + { + "should-publish=false" >> $Env:GITHUB_OUTPUT + exit 0 + } + + $assetPaths = @( + 'gulpfile.js', + 'package.json', + 'package-lock.json', + 'src/Resources/CrestApps.AI.Resources/Assets.json', + 'src/Resources/CrestApps.AI.Resources/package.json', + 'src/Resources/CrestApps.AI.Resources/Assets', + 'src/Resources/CrestApps.AI.Resources/scripts', + 'src/Resources/CrestApps.AI.Resources/wwwroot' + ) + + $changedAssetFiles = @(git diff --name-only $previousRun.head_sha '${{ github.sha }}' -- $assetPaths | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + + $hasAssetChangesSinceLastRelease = $changedAssetFiles.Count -gt 0 + Write-Output "Asset changes since last preview publish: $hasAssetChangesSinceLastRelease" + + if ($hasAssetChangesSinceLastRelease) + { + Write-Output 'Changed asset-related files:' + $changedAssetFiles | ForEach-Object { Write-Output " - $_" } + } + + $shouldPublish = $eventName -eq 'schedule' -and $hasAssetChangesSinceLastRelease "should-publish=$($shouldPublish ? 'true' : 'false')" >> $Env:GITHUB_OUTPUT - uses: actions/setup-node@v6 diff --git a/.github/workflows/validate_prompts.yml b/.github/workflows/validate_prompts.yml index 705d6531..f4a7f671 100644 --- a/.github/workflows/validate_prompts.yml +++ b/.github/workflows/validate_prompts.yml @@ -22,9 +22,15 @@ jobs: with: dotnet-version: | 10.0.x + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - name: Install Python dependencies + run: | + python -m pip install --disable-pip-version-check pyyaml - name: Build prompt validation tool run: | - dotnet build -c Release src/Common/CrestApps.Core.Templates/CrestApps.Core.Templates.csproj /p:NuGetAudit=false + dotnet build -c Release src/Primitives/CrestApps.Core.Templates/CrestApps.Core.Templates.csproj /p:NuGetAudit=false - name: Validate template file structure shell: bash run: | @@ -207,4 +213,3 @@ jobs: print("✓ All Parameters entries are valid") sys.exit(exit_code) - diff --git a/CrestApps.Core.slnx b/CrestApps.Core.slnx index 0e079010..9a876b49 100644 --- a/CrestApps.Core.slnx +++ b/CrestApps.Core.slnx @@ -52,7 +52,9 @@ + + @@ -62,6 +64,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index 4313c504..6e4e4e2a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -59,6 +59,7 @@ + diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs index 80cbb1fa..e9d70e77 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Clients/IAIClientProvider.cs @@ -37,7 +37,6 @@ public interface IAIClientProvider /// The connection entry containing provider configuration. /// The optional deployment name to use. /// A representing the asynchronous operation. - #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. ValueTask GetImageGeneratorAsync(AIProviderConnectionEntry connection, string deploymentName = null); #pragma warning restore MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/AIDeploymentConfigurationException.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/AIDeploymentConfigurationException.cs new file mode 100644 index 00000000..7d4514fa --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/AIDeploymentConfigurationException.cs @@ -0,0 +1,14 @@ +namespace CrestApps.Core.AI.Exceptions; + +public class AIDeploymentConfigurationException : Exception +{ + public AIDeploymentConfigurationException(string message) + : base(message) + { + } + + public AIDeploymentConfigurationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/AIDeploymentNotFoundException.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/AIDeploymentNotFoundException.cs new file mode 100644 index 00000000..d8623e90 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Exceptions/AIDeploymentNotFoundException.cs @@ -0,0 +1,14 @@ +namespace CrestApps.Core.AI.Exceptions; + +public sealed class AIDeploymentNotFoundException : AIDeploymentConfigurationException +{ + public AIDeploymentNotFoundException(string message) + : base(message) + { + } + + public AIDeploymentNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs index 483541a2..30ad17f7 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Models/PreemptiveRagContext.cs @@ -1,4 +1,4 @@ -using CrestApps.Core.AI.Memory; +using CrestApps.Core.AI.Orchestration; namespace CrestApps.Core.AI.Models; diff --git a/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IPreemptiveRagHandler.cs b/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IPreemptiveRagHandler.cs index b1d3138e..75af0867 100644 --- a/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IPreemptiveRagHandler.cs +++ b/src/Abstractions/CrestApps.Core.AI.Abstractions/Orchestration/IPreemptiveRagHandler.cs @@ -1,6 +1,6 @@ using CrestApps.Core.AI.Models; -namespace CrestApps.Core.AI.Memory; +namespace CrestApps.Core.AI.Orchestration; /// /// Defines a handler that processes preemptive RAG (Retrieval-Augmented Generation) for a specific diff --git a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchDocumentHandler.cs b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchDocumentHandler.cs index a395ee3a..b2986df1 100644 --- a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchDocumentHandler.cs +++ b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchDocumentHandler.cs @@ -17,8 +17,7 @@ public interface ISearchDocumentHandler Task DocumentsAddedOrUpdatedAsync( IIndexProfileInfo profile, IReadOnlyCollection documentIds, - CancellationToken cancellationToken = default) - => Task.CompletedTask; + CancellationToken cancellationToken = default); /// /// Called after successfully deletes documents from a source index. @@ -30,6 +29,5 @@ Task DocumentsAddedOrUpdatedAsync( Task DocumentsDeletedAsync( IIndexProfileInfo profile, IReadOnlyCollection documentIds, - CancellationToken cancellationToken = default) - => Task.CompletedTask; + CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileProvisioningService.cs b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileProvisioningService.cs new file mode 100644 index 00000000..1c334967 --- /dev/null +++ b/src/Abstractions/CrestApps.Core.Infrastructure.Abstractions/Indexing/ISearchIndexProfileProvisioningService.cs @@ -0,0 +1,9 @@ +using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Models; + +namespace CrestApps.Core.Infrastructure.Indexing; + +public interface ISearchIndexProfileProvisioningService +{ + Task CreateAsync(SearchIndexProfile profile, CancellationToken cancellationToken = default); +} 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 ddfd9d8b..83d013dd 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -36,8 +36,12 @@ description: Initial standalone release notes for the CrestApps.Core repository. - treats aborted and canceled request-stream failures in the Aspire AppHost as observed task exceptions so local development no longer floods the console with benign unobserved-task noise - generates external `.map` source map files for all JS and CSS assets in the gulp build pipeline, copies them into `dist/` during npm package preparation, and includes them in the `@crestapps/ai-chat-ui` package exports - adds per-message text-to-speech play/pause controls on assistant messages in the AI Chat and Chat Interaction UIs, keeps the action toolbar pinned to the bottom-right of each response without reserving a separate action row, automatically stops other message players before starting a new one, and hides manual playback controls during Conversation mode +- renders sample-host `[doc:n]` citations as superscript markers and shows the resolved document links below each cited assistant response in both the MVC and Blazor chat UIs +- adds `AddReferenceDownloads()` plus `AddDownloadAIDocumentEndpoint()` so attached-document citation links can be registered and downloaded explicitly in sample or custom hosts - upgrades the MVC sample host to Font Awesome 7 and adds draggable, resizable AI Chat widget layout persistence with a reset-size control that hosts can disable through widget config - adds Chat History page listing previous sessions per AI Profile sorted by creation date, with resume, delete, delete-all, and new-chat actions - adds Test page for Utility and Agent AI Profiles providing a single-prompt/single-response streamed UI - renames the "Chat" button to "New Chat" on the AI Profile list and adds "Chat History" and "Test" buttons for applicable profile types - splits document ingestion, document-processing services, document endpoints, and document RAG into the dedicated `CrestApps.Core.AI.Documents` package, renames the format-specific helpers to `CrestApps.Core.AI.Documents.OpenXml` and `CrestApps.Core.AI.Documents.Pdf`, removes the data-ingestion dependency from `CrestApps.Core.AI`, persists uploaded files through `IDocumentFileStore` with GUID-based stored file names plus database-backed stored file metadata so hosts can redirect or clean up physical files reliably, and now registers a default filesystem-backed `IDocumentFileStore` from `AddCoreAIDocumentProcessing()` with `DocumentFileSystemFileStoreOptions` for base-path overrides +- simplifies template discovery by splitting generic `Templates/` loading from prompt-only `Templates/Prompts/`, keeps generic file discovery flat so provider-specific subfolders are not double-loaded, adds `Kind`-based template selection through `ITemplateService`, suppresses duplicate template IDs with first-match wins behavior, and removes Orchard-specific embedded-resource path handling from the standalone framework templating providers +- registers shared indexing services in the framework by default, including `ISearchIndexProfileManager`, `ISearchIndexProfileProvisioningService`, and a null fallback `ISearchIndexProfileStore`, so hosts only need `.AddIndexingServices(...).AddYesSqlStores()` or `.AddEntityCoreStores()` when they want persisted index profile records diff --git a/src/CrestApps.Core.Docs/docs/core/ai-memory.md b/src/CrestApps.Core.Docs/docs/core/ai-memory.md index e856ea73..414b0921 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-memory.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-memory.md @@ -37,7 +37,7 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps The framework does not assume a single persistence model. A host application is responsible for wiring the storage and search pieces that match its runtime: - an `IAIMemoryStore` implementation for durable memory entries -- an `ISearchIndexProfileStore` implementation for index profile lookup (registered via `.AddIndexingServices(indexing => indexing.AddEntityCoreStores())` or `.AddYesSqlStores()`) +- a persistent `ISearchIndexProfileStore` implementation for index profile lookup when you want saved index profiles (registered via `.AddIndexingServices(indexing => indexing.AddEntityCoreStores())` or `.AddYesSqlStores()`). `AddCoreAIServices()` already supplies a null fallback store so hosts can start before a persistent store is added. - one or more keyed `IMemoryVectorSearchService` implementations - options such as `AIMemoryOptions`, `GeneralAIOptions`, and `ChatInteractionMemoryOptions` diff --git a/src/CrestApps.Core.Docs/docs/core/ai-templates.md b/src/CrestApps.Core.Docs/docs/core/ai-templates.md index e731f843..5cbcc9ff 100644 --- a/src/CrestApps.Core.Docs/docs/core/ai-templates.md +++ b/src/CrestApps.Core.Docs/docs/core/ai-templates.md @@ -38,7 +38,8 @@ Hard-coding system prompts in C# makes them difficult to maintain, localize, and | `ITemplateEngine` | `FluidTemplateEngine` | Singleton | Renders Liquid templates | | `ITemplateService` | `DefaultTemplateService` | Scoped | Unified template discovery and rendering | | `OptionsTemplateProvider` | — | Singleton | Templates registered via code | -| `FileSystemTemplateProvider` | — | Singleton | Templates discovered from disk | +| `FileSystemTemplateProvider` | — | Singleton | Generic templates discovered directly from `Templates/` | +| `PromptsFileSystemTemplateProvider` | — | Singleton | System prompt templates discovered from `Templates/Prompts/` | ## Key Interfaces @@ -84,7 +85,29 @@ public interface ITemplateProvider ## Template File Format -Templates are markdown files with YAML front matter, stored in `Templates/Prompts/`: +Templates are markdown files with YAML front matter. You can either: + +- store generic templates in `Templates/` and set `Kind` explicitly +- store system prompt templates in `Templates/Prompts/`, which defaults `Kind` to `SystemPrompt` + +Example generic template: + +```markdown +--- +Title: Helpful Assistant +Kind: SystemPrompt +Description: A general-purpose helpful assistant prompt +Category: General +IsListable: true +--- +You are a helpful assistant. Today's date is {{ "now" | date: "%Y-%m-%d" }}. + +{% if user_name %} +You are assisting {{ user_name }}. +{% endif %} +``` + +The same content can live under `Templates/Prompts/` without repeating `Kind`: ```markdown --- @@ -108,12 +131,13 @@ You are assisting {{ user_name }}. | `Description` | string | Human-readable description | | `Category` | string | Grouping category | | `IsListable` | bool | Whether the template appears in listing APIs | +| `Kind` | string | Semantic template kind such as `SystemPrompt` or `Profile` | ## Registering Templates ### From Embedded Resources -Store `.md` files as embedded resources under `Templates/Prompts/` in your assembly: +Store `.md` files as embedded resources under `Templates/` in your assembly. Files under `Templates/Prompts/` default to `SystemPrompt`; files elsewhere should set `Kind` in front matter: ```csharp builder.Services.AddTemplatesFromAssembly(typeof(MyClass).Assembly, source: "MyApp"); @@ -140,6 +164,13 @@ builder.Services.AddTemplating(options => }); ``` +With that discovery path: + +- `/app/templates/Templates/*.md` is treated as the generic template root and should declare `Kind` +- `/app/templates/Templates/Prompts/*.md` is treated as prompt-only and defaults `Kind` to `SystemPrompt` + +Generic `Templates/` discovery is intentionally not recursive. Subfolders can be reserved for other provider-specific conventions without being double-loaded. + ## Configuration ### `TemplateOptions` @@ -414,7 +445,6 @@ builder.Services.AddSingleton(); ``` :::info -All registered `ITemplateProvider` instances are queried by `ITemplateService`. Templates from multiple providers are merged into a single collection. If two providers return templates with the same `Id`, the last-registered provider wins. +All registered `ITemplateProvider` instances are queried by `ITemplateService`. Templates from multiple providers are merged into a single collection. If two providers return templates with the same `Id`, the first discovered template wins and later duplicates are ignored. ::: - diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index 0f53c704..6c9f4d39 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -64,6 +64,8 @@ The chat system provides all of this with a pluggable handler architecture. In the MVC sample, Chat Interactions now reserve automatic spoken playback for **active conversation mode** only. Typed prompts and microphone dictation still produce normal streamed text responses, but they no longer auto-read the assistant reply unless the user explicitly started the live two-way conversation flow. +Both the MVC and Blazor sample hosts now render `[doc:n]` citations as superscript markers in assistant responses and show the resolved document references as clickable links directly below the cited message. When a citation points to an attached AI document, the reference link now downloads that file from the server after the host registers both `AddReferenceDownloads()` on the document-processing builder and `AddDownloadAIDocumentEndpoint()` on the endpoint route builder. + ## Services Registered by `AddCoreAIChatInteractions()` | Service | Implementation | Lifetime | Purpose | @@ -800,4 +802,3 @@ window.openAIChatManager.initialize({ ``` The play icon appears on completed assistant messages when a TTS deployment is available on the server. In Conversation mode, the per-message playback icon is hidden so the live voice exchange is not interrupted by manual playback controls. - diff --git a/src/CrestApps.Core.Docs/docs/core/data-storage.md b/src/CrestApps.Core.Docs/docs/core/data-storage.md index e21b7c4f..594a26c0 100644 --- a/src/CrestApps.Core.Docs/docs/core/data-storage.md +++ b/src/CrestApps.Core.Docs/docs/core/data-storage.md @@ -186,6 +186,8 @@ Every CrestApps feature that needs persistent storage exposes `.AddYesSqlStores( The **AI Services** builder method (`.AddYesSqlStores()` / `.AddEntityCoreStores()` on `CrestAppsAISuiteBuilder`) is a convenience that registers AI Profile Template and Chat Session stores together. Implementations that need finer-grained control (e.g., Orchard Core) can call the individual `IServiceCollection` extensions (`AddCoreAIProfileTemplateStoresYesSql()`, `AddCoreAIMcpServerStoresYesSql()`, etc.) directly. ::: +`AddCoreAIServices()` now registers the shared indexing runtime services (`ISearchIndexProfileManager`, `ISearchIndexProfileProvisioningService`, and a null fallback `ISearchIndexProfileStore`). Call `.AddIndexingServices(indexing => indexing.AddEntityCoreStores())` or `.AddYesSqlStores()` when you want persisted index profile records instead of the fallback. + **Entity Framework Core example** — register stores inline with each feature: ```csharp @@ -1090,4 +1092,3 @@ builder.Services.AddScoped, CustomNamedSourceCatalog, CustomNamedSourceCatalog>(); ``` - diff --git a/src/CrestApps.Core.Docs/docs/core/document-processing.md b/src/CrestApps.Core.Docs/docs/core/document-processing.md index ff140733..651631f4 100644 --- a/src/CrestApps.Core.Docs/docs/core/document-processing.md +++ b/src/CrestApps.Core.Docs/docs/core/document-processing.md @@ -22,11 +22,15 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps .AddEntityCoreStores() .AddOpenXml() .AddPdf() + .AddReferenceDownloads() ) .AddOpenAI() ) .AddEntityCoreSqliteDataStore("Data Source=app.db") ); + +app.AddChatApiEndpoints() + .AddDownloadAIDocumentEndpoint(); ``` ## Problem & Solution @@ -52,6 +56,41 @@ The document processing system handles the full pipeline from upload to retrieva `AddCoreAIDocumentProcessing()` and the `AddDocumentProcessing(...)` builder extension are provided by `CrestApps.Core.AI.Documents`. +### Citation download links + +Attached-document citations are an opt-in document-processing feature made of two registrations: + +1. `AddReferenceDownloads()` on `CrestAppsDocumentProcessingBuilder` (or `AddCoreAIDocumentReferenceDownloads()` on `IServiceCollection`) registers `DocumentAIReferenceLinkResolver` for `AIReferenceTypes.DataSource.Document`. +2. `AddDownloadAIDocumentEndpoint()` maps the shared download route that serves the cited file back to the browser. + +Use both when you want `[doc:n]` references for attached AI documents to render as downloadable links in your chat UI: + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddDocumentProcessing(documentProcessing => documentProcessing + .AddEntityCoreStores() + .AddOpenXml() + .AddPdf() + .AddReferenceDownloads() + ) + ) +); + +app.AddChatApiEndpoints() + .AddDownloadAIDocumentEndpoint(); +``` + +If you prefer the raw service surface instead of the builder API: + +```csharp +builder.Services.AddCoreAIDocumentProcessing(); +builder.Services.AddCoreAIDocumentReferenceDownloads(); + +app.AddChatApiEndpoints() + .AddDownloadAIDocumentEndpoint(); +``` + ### Built-in Document Readers `AddDocumentProcessing(...)` registers the plain-text and tabular readers. OpenXml and PDF readers now live in the dedicated `CrestApps.Core.AI.Documents.OpenXml` and `CrestApps.Core.AI.Documents.Pdf` packages, so hosts opt into those dependencies explicitly with the nested builder calls `AddOpenXml()` and `AddPdf()` or, if they prefer the raw `IServiceCollection` surface, `AddCoreAIOpenXmlDocumentProcessing()` and `AddCoreAIPdfDocumentProcessing()`. Markdown-aware normalization now also lives in its own `CrestApps.Core.AI.Markdown` package. `AddAISuite(...)` does not register it automatically, so hosts that want Markdig-backed normalization and chunking must opt in with `AddMarkdown()` or `AddCoreAIMarkdown()`. @@ -160,6 +199,7 @@ Document metadata and chunks require store implementations. Register stores dire .AddEntityCoreStores() .AddOpenXml() .AddPdf() + .AddReferenceDownloads() ) ``` @@ -170,6 +210,7 @@ Document metadata and chunks require store implementations. Register stores dire .AddYesSqlStores() .AddOpenXml() .AddPdf() + .AddReferenceDownloads() ) ``` @@ -184,5 +225,3 @@ builder.Services.AddSingleton(); The MVC sample host stores uploads on the local file system. Each upload gets a new GUID-based stored file name to avoid collisions, while the original user-facing file name remains in `AIDocument.FileName`. The persisted document record also keeps the GUID-based stored file name/path (`StoredFileName` / `StoredFilePath`) so hosts can trace and delete the physical file later. Replace `IDocumentFileStore` when you want uploaded profile, chat-interaction, or chat-session files to land in a different backend such as Azure Blob Storage instead of the local file system used by the MVC sample host. - - diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs index dc1cd72c..24da6262 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/AIChatHubCore.cs @@ -5,6 +5,7 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Profiles; @@ -122,9 +123,19 @@ protected virtual string GetNotAuthorizedMessage() protected virtual string GetFriendlyErrorMessage(Exception ex) { + if (AIHubErrorMessageHelper.IsInvalidChatModelSettingsFailure(ex)) + { + return GetInvalidChatModelSettingsMessage(); + } + return "An error occurred processing your message."; } + protected virtual string GetInvalidChatModelSettingsMessage() + { + return "The chat model settings are missing or invalid. Update the Chat model in the AI Profile or the global AI settings."; + } + protected virtual string GetOnlyChatProfilesMessage() { return "Only chat profiles can start chat sessions."; @@ -898,7 +909,18 @@ protected virtual async Task ProcessChatPromptAsync(ChannelWriter !x.IsGeneratedPrompt).Select(p => new ChatMessage(p.Role, p.Content)).ToList(); + var conversationHistorySource = existingPrompts.ToList(); + + if (!conversationHistorySource.Any(x => x.ItemId == userPromptRecord.ItemId)) + { + conversationHistorySource.Add(userPromptRecord); + } + + var conversationHistory = conversationHistorySource + .OrderBy(x => x.CreatedUtc) + .Where(x => !x.IsGeneratedPrompt) + .Select(p => new ChatMessage(p.Role, p.Content)) + .ToList(); // Resolve the chat response handler for this session. var chatMode = profile.TryGetSettings(out var chatModeSettings) ? chatModeSettings.ChatMode : ChatMode.TextInput; var handler = handlerResolver.Resolve(chatSession.ResponseHandlerName, chatMode); @@ -1009,7 +1031,8 @@ protected virtual async Task ProcessGeneratedPromptAsync(ChannelWriter(); - var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName) ?? throw new InvalidOperationException("Unable to resolve a chat deployment for the profile."); + var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName) + ?? throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the profile."); using var builder = ZString.CreateStringBuilder(); var contentItemIds = new HashSet(); var references = new Dictionary(); @@ -1048,7 +1071,8 @@ protected virtual async Task ProcessUtilityAsync(ChannelWriter(); var messageId = GenerateId(); var completionContext = await completionContextBuilder.BuildAsync(profile); - var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName) ?? throw new InvalidOperationException("Unable to resolve a chat deployment for the profile."); + var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName) + ?? throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the profile."); var references = new Dictionary(); await foreach (var chunk in completionService.CompleteStreamingAsync(chatDeployment, [new ChatMessage(ChatRole.User, prompt)], completionContext, cancellationToken)) { @@ -1402,7 +1426,12 @@ private async Task ProcessConversationPromptAsync(AIProfile profile, str responseId ??= chunk.ResponseId; if (!string.IsNullOrEmpty(chunk.Content)) { - await Clients.Caller.ReceiveConversationAssistantToken(effectiveSessionId, messageId ?? string.Empty, chunk.Content, responseId ?? string.Empty); + await Clients.Caller.ReceiveConversationAssistantToken( + effectiveSessionId, + messageId ?? string.Empty, + chunk.Content, + responseId ?? string.Empty, + chunk.References); sentenceBuffer.Append(chunk.Content); if (SentenceBoundaryDetector.EndsWithSentenceBoundary(chunk.Content)) { @@ -1448,7 +1477,10 @@ private async Task ProcessConversationPromptAsync(AIProfile profile, str { try { - await Clients.Caller.ReceiveConversationAssistantComplete(effectiveSessionId, messageId); + await Clients.Caller.ReceiveConversationAssistantComplete( + effectiveSessionId, + messageId, + await GetPromptReferencesAsync(services, effectiveSessionId, messageId)); } catch { @@ -1460,6 +1492,23 @@ private async Task ProcessConversationPromptAsync(AIProfile profile, str return effectiveSessionId; } + private static async Task> GetPromptReferencesAsync( + IServiceProvider services, + string sessionId, + string messageId) + { + if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(messageId)) + { + return null; + } + + var promptStore = services.GetRequiredService(); + var prompts = await promptStore.GetPromptsAsync(sessionId); + var prompt = prompts.FirstOrDefault(entry => string.Equals(entry.ItemId, messageId, StringComparison.Ordinal)); + + return prompt?.References; + } + #pragma warning restore MEAI001 #pragma warning disable MEAI001 diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs index 57731a2a..e7c3bcf9 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/ChatInteractionHubBase.cs @@ -167,9 +167,19 @@ protected virtual string GetNotAuthorizedMessage() protected virtual string GetFriendlyErrorMessage(Exception ex) { + if (AIHubErrorMessageHelper.IsInvalidChatModelSettingsFailure(ex)) + { + return GetInvalidChatModelSettingsMessage(); + } + return "An error occurred while processing your message."; } + protected virtual string GetInvalidChatModelSettingsMessage() + { + return "The chat model settings are missing or invalid. Update the Chat model in this chat interaction, the linked AI Profile, or the global AI settings."; + } + protected virtual string GetConversationNotEnabledMessage() { return "Conversation mode is not enabled for chat interactions."; @@ -849,7 +859,15 @@ protected virtual async Task HandlePromptAsync( } var existingPrompts = await promptStore.GetPromptsAsync(itemId); - var conversationHistory = existingPrompts + var conversationHistorySource = existingPrompts.ToList(); + + if (!conversationHistorySource.Any(x => x.ItemId == userPrompt.ItemId)) + { + conversationHistorySource.Add(userPrompt); + } + + var conversationHistory = conversationHistorySource + .OrderBy(x => x.CreatedUtc) .Where(x => !x.IsGeneratedPrompt) .Select(p => new ChatMessage(p.Role, p.Text)) .ToList(); @@ -1278,8 +1296,12 @@ private async Task ProcessConversationPromptAsync( } await Clients.Caller.ReceiveConversationAssistantToken( - itemId, messageId ?? string.Empty, chunk.Content, - responseId ?? string.Empty, chunk.Appearance); + itemId, + messageId ?? string.Empty, + chunk.Content, + responseId ?? string.Empty, + chunk.References, + chunk.Appearance); sentenceBuffer.Append(chunk.Content); @@ -1329,7 +1351,10 @@ await Clients.Caller.ReceiveConversationAssistantToken( { try { - await Clients.Caller.ReceiveConversationAssistantComplete(itemId, messageId); + await Clients.Caller.ReceiveConversationAssistantComplete( + itemId, + messageId, + await GetPromptReferencesAsync(services, itemId, messageId)); } catch { @@ -1339,6 +1364,23 @@ await Clients.Caller.ReceiveConversationAssistantToken( } } + private static async Task> GetPromptReferencesAsync( + IServiceProvider services, + string itemId, + string messageId) + { + if (string.IsNullOrWhiteSpace(itemId) || string.IsNullOrWhiteSpace(messageId)) + { + return null; + } + + var promptStore = services.GetRequiredService(); + var prompts = await promptStore.GetPromptsAsync(itemId); + var prompt = prompts.FirstOrDefault(entry => string.Equals(entry.ItemId, messageId, StringComparison.Ordinal)); + + return prompt?.References; + } + // ───────────────── STT transcription (input mode) ───────────────── private async Task StreamTranscriptionAsync( diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IAIChatHubClient.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IAIChatHubClient.cs index f278f151..463944c8 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IAIChatHubClient.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IAIChatHubClient.cs @@ -65,14 +65,22 @@ public interface IAIChatHubClient /// The assistant message identifier. /// The response token text. /// The response identifier for grouping tokens. - Task ReceiveConversationAssistantToken(string identifier, string messageId, string token, string responseId); + Task ReceiveConversationAssistantToken( + string identifier, + string messageId, + string token, + string responseId, + Dictionary references = null); /// /// Notifies the client that the assistant response in conversation mode is complete. /// /// The conversation turn identifier. /// The assistant message identifier. - Task ReceiveConversationAssistantComplete(string identifier, string messageId); + Task ReceiveConversationAssistantComplete( + string identifier, + string messageId, + Dictionary references = null); // Notification system messages. diff --git a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IChatInteractionHubClient.cs b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IChatInteractionHubClient.cs index 355e5b74..01a28d78 100644 --- a/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IChatInteractionHubClient.cs +++ b/src/Primitives/CrestApps.Core.AI.Chat/Hubs/IChatInteractionHubClient.cs @@ -42,9 +42,18 @@ public interface IChatInteractionHubClient Task ReceiveConversationUserMessage(string identifier, string text); - Task ReceiveConversationAssistantToken(string identifier, string messageId, string token, string responseId, AssistantMessageAppearance appearance = null); - - Task ReceiveConversationAssistantComplete(string identifier, string messageId); + Task ReceiveConversationAssistantToken( + string identifier, + string messageId, + string token, + string responseId, + Dictionary references = null, + AssistantMessageAppearance appearance = null); + + Task ReceiveConversationAssistantComplete( + string identifier, + string messageId, + Dictionary references = null); Task ReceiveNotification(ChatNotification notification); diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs new file mode 100644 index 00000000..f03baded --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs @@ -0,0 +1,141 @@ +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; +using CrestApps.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; + +namespace CrestApps.Core.AI.Documents.Endpoints; + +public static class DownloadAIDocument +{ + public const string DefaultRouteName = "DownloadAIDocument"; + + /// + /// Adds the shared AI document download endpoint used by citation links. + /// + public static IEndpointRouteBuilder AddDownloadAIDocumentEndpoint(this IEndpointRouteBuilder builder, string routeName = DefaultRouteName) + { + var endpoint = builder.MapGet("ai/documents/{documentId}/download", HandleAsync); + + if (!string.IsNullOrEmpty(routeName)) + { + _ = endpoint.WithName(routeName); + } + + return builder; + } + + private static async Task HandleAsync( + string documentId, + HttpContext httpContext, + [FromServices] IAIDocumentStore documentStore, + [FromServices] IDocumentFileStore fileStore, + [FromServices] IAuthorizationService authorizationService, + [FromServices] ICatalogManager interactionManager, + [FromServices] IAIChatSessionManager sessionManager, + [FromServices] IAIProfileManager profileManager) + { + if (string.IsNullOrWhiteSpace(documentId)) + { + return Results.BadRequest(); + } + + var document = await documentStore.FindByIdAsync(documentId); + + if (document is null || string.IsNullOrWhiteSpace(document.StoredFilePath)) + { + return Results.NotFound(); + } + + var authorizationResult = await AuthorizeAsync( + httpContext, + authorizationService, + interactionManager, + sessionManager, + profileManager, + document); + + if (authorizationResult is not null) + { + return authorizationResult; + } + + var stream = await fileStore.GetFileAsync(document.StoredFilePath); + + if (stream is null) + { + return Results.NotFound(); + } + + return Results.File( + stream, + string.IsNullOrWhiteSpace(document.ContentType) ? "application/octet-stream" : document.ContentType, + document.FileName, + enableRangeProcessing: true); + } + + private static async Task AuthorizeAsync( + HttpContext httpContext, + IAuthorizationService authorizationService, + ICatalogManager interactionManager, + IAIChatSessionManager sessionManager, + IAIProfileManager profileManager, + AIDocument document) + { + switch (document.ReferenceType) + { + case AIReferenceTypes.Document.ChatInteraction: + { + var interaction = await interactionManager.FindByIdAsync(document.ReferenceId); + + if (interaction is null) + { + return Results.NotFound(); + } + + var authorization = await authorizationService.AuthorizeAsync( + httpContext.User, + interaction, + [AIChatDocumentOperations.ManageDocuments]); + + return authorization.Succeeded ? null : CreateUnauthorizedResult(httpContext); + } + case AIReferenceTypes.Document.ChatSession: + { + var session = await sessionManager.FindAsync(document.ReferenceId); + + if (session is null) + { + return Results.NotFound(); + } + + var profile = await profileManager.FindByIdAsync(session.ProfileId); + + if (profile is null) + { + return Results.NotFound(); + } + + var authorization = await authorizationService.AuthorizeAsync( + httpContext.User, + new AIChatSessionDocumentAuthorizationContext(profile, session), + [AIChatDocumentOperations.ManageDocuments]); + + return authorization.Succeeded ? null : CreateUnauthorizedResult(httpContext); + } + default: + return Results.NotFound(); + } + } + + private static IResult CreateUnauthorizedResult(HttpContext httpContext) + { + return httpContext.User.Identity?.IsAuthenticated == true + ? Results.Forbid() + : Results.Challenge(); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/RemoveChatInteractionDocument.cs b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/RemoveChatInteractionDocument.cs index 63591be7..2f267a34 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/RemoveChatInteractionDocument.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/RemoveChatInteractionDocument.cs @@ -67,16 +67,33 @@ public static async Task HandleAsync( return TypedResults.Forbid(); } + var document = await documentStore.FindByIdAsync(requestModel.DocumentId); var documentInfo = interaction.Documents?.FirstOrDefault(document => document.DocumentId == requestModel.DocumentId); + if (documentInfo == null && document != null) + { + documentInfo = new ChatDocumentInfo + { + DocumentId = document.ItemId, + FileName = document.FileName, + FileSize = document.FileSize, + ContentType = document.ContentType, + }; + } + if (documentInfo == null) { return TypedResults.NotFound("Document not found."); } - interaction.Documents.Remove(documentInfo); - - var document = await documentStore.FindByIdAsync(requestModel.DocumentId); + if (interaction.Documents != null) + { + var attachedDocument = interaction.Documents.FirstOrDefault(existingDocument => existingDocument.DocumentId == requestModel.DocumentId); + if (attachedDocument != null) + { + interaction.Documents.Remove(attachedDocument); + } + } var chunkIds = new List(); diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs index b83da9b5..2e2e9001 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Handlers/DocumentPreemptiveRagHandler.cs @@ -2,13 +2,13 @@ using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Documents.Services; -using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Services; using CrestApps.Core.AI.Tooling; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Models; using CrestApps.Core.Templates.Services; using Cysharp.Text; using Microsoft.Extensions.DependencyInjection; @@ -27,7 +27,6 @@ internal sealed class DocumentPreemptiveRagHandler : IPreemptiveRagHandler private readonly IAIDeploymentManager _deploymentManager; private readonly ISearchIndexProfileStore _indexProfileStore; private readonly ITemplateService _templateService; - private readonly InteractionDocumentOptions _options; private readonly IAITextNormalizer _textNormalizer; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -37,7 +36,6 @@ public DocumentPreemptiveRagHandler( IAIDeploymentManager deploymentManager, ISearchIndexProfileStore indexProfileStore, ITemplateService templateService, - IOptions options, IAITextNormalizer textNormalizer, IServiceProvider serviceProvider, ILogger logger) @@ -46,7 +44,6 @@ public DocumentPreemptiveRagHandler( _deploymentManager = deploymentManager; _indexProfileStore = indexProfileStore; _templateService = templateService; - _options = options.Value; _textNormalizer = textNormalizer; _serviceProvider = serviceProvider; _logger = logger; @@ -74,14 +71,20 @@ public ValueTask CanHandleAsync(OrchestrationContextBuiltContext context) public async Task HandleAsync(PreemptiveRagContext context) { - if (string.IsNullOrEmpty(_options.IndexProfileName)) + var snapshotSettings = _serviceProvider.GetService>()?.Value; + var optionsSettings = _serviceProvider.GetRequiredService>().Value; + var defaultSettings = !string.IsNullOrWhiteSpace(snapshotSettings?.IndexProfileName) + ? snapshotSettings + : optionsSettings; + + if (string.IsNullOrEmpty(defaultSettings.IndexProfileName)) { return; } try { - await InjectPreemptiveRagContextAsync(context, ResolveSettings(context.Resource, _options)); + await InjectPreemptiveRagContextAsync(context, ResolveSettings(context.Resource, defaultSettings)); } catch (Exception ex) { @@ -286,8 +289,8 @@ context.Resource is not AIProfile || private static InteractionDocumentOptions ResolveSettings(object resource, InteractionDocumentOptions defaults) { - if (resource is AIProfile profile && - profile.TryGet(out var metadata)) + if (resource is CatalogItem item && + item.TryGet(out var metadata)) { return new InteractionDocumentOptions { diff --git a/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs index 4d8d1268..57df8b54 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/ServiceCollectionExtensions.cs @@ -3,8 +3,8 @@ using CrestApps.Core.AI.Documents.Models; using CrestApps.Core.AI.Documents.Services; using CrestApps.Core.AI.Documents.Tools; -using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Profiles; using CrestApps.Core.AI.Services; using CrestApps.Core.AI.Tooling; using CrestApps.Core.Builders; @@ -107,6 +107,18 @@ public static IServiceCollection AddCoreAIDocumentProcessing(this IServiceCollec return services; } + /// + /// Adds document reference-link services so cited AI documents resolve to downloadable links. + /// + public static IServiceCollection AddCoreAIDocumentReferenceDownloads(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddKeyedScoped(AIReferenceTypes.DataSource.Document); + + return services; + } + public static CrestAppsAISuiteBuilder AddDocumentProcessing(this CrestAppsAISuiteBuilder builder, Action configure = null) { ArgumentNullException.ThrowIfNull(builder); @@ -121,6 +133,18 @@ public static CrestAppsAISuiteBuilder AddDocumentProcessing(this CrestAppsAISuit return builder; } + /// + /// Adds document reference-link services so cited AI documents resolve to downloadable links. + /// + public static CrestAppsDocumentProcessingBuilder AddReferenceDownloads(this CrestAppsDocumentProcessingBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddCoreAIDocumentReferenceDownloads(); + + return builder; + } + public static IServiceCollection AddCoreAIDocumentIndexProfileHandler(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs new file mode 100644 index 00000000..79e29172 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs @@ -0,0 +1,39 @@ +using CrestApps.Core.AI.Documents.Endpoints; +using CrestApps.Core.AI.Profiles; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace CrestApps.Core.AI.Documents.Services; + +/// +/// Resolves citation links for stored AI documents to the shared download endpoint. +/// +public sealed class DocumentAIReferenceLinkResolver : IAIReferenceLinkResolver +{ + private readonly LinkGenerator _linkGenerator; + private readonly IHttpContextAccessor _httpContextAccessor; + + public DocumentAIReferenceLinkResolver( + LinkGenerator linkGenerator, + IHttpContextAccessor httpContextAccessor) + { + _linkGenerator = linkGenerator; + _httpContextAccessor = httpContextAccessor; + } + + public string ResolveLink(string referenceId, IDictionary metadata) + { + if (string.IsNullOrWhiteSpace(referenceId)) + { + return null; + } + + return _linkGenerator.GetPathByName( + _httpContextAccessor.HttpContext, + DownloadAIDocument.DefaultRouteName, + new RouteValueDictionary + { + ["documentId"] = referenceId, + }); + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs index d783027f..4313f76d 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Tools/SearchDocumentsTool.cs @@ -10,6 +10,7 @@ using CrestApps.Core.AI.Tooling; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Models; using Cysharp.Text; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; @@ -92,7 +93,11 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a _ => false, }; - var defaultSettings = arguments.Services.GetRequiredService>().Value; + var snapshotSettings = arguments.Services.GetService>()?.Value; + var optionsSettings = arguments.Services.GetRequiredService>().Value; + var defaultSettings = !string.IsNullOrWhiteSpace(snapshotSettings?.IndexProfileName) + ? snapshotSettings + : optionsSettings; var settings = ResolveSettings(executionContext?.Resource, defaultSettings); if (string.IsNullOrWhiteSpace(settings.IndexProfileName)) @@ -223,8 +228,8 @@ protected override async ValueTask InvokeCoreAsync(AIFunctionArguments a private static InteractionDocumentOptions ResolveSettings(object resource, InteractionDocumentOptions defaults) { - if (resource is AIProfile profile && - profile.TryGet(out var metadata)) + if (resource is CatalogItem item && + item.TryGet(out var metadata)) { return new InteractionDocumentOptions { diff --git a/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs b/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs index 4673a135..554af663 100644 --- a/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs +++ b/src/Primitives/CrestApps.Core.AI/AIHubErrorMessageHelper.cs @@ -1,4 +1,5 @@ using System.Net; +using CrestApps.Core.AI.Exceptions; using Microsoft.Extensions.Localization; namespace CrestApps.Core.AI; @@ -58,6 +59,19 @@ HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden return S["Our service is currently unavailable. Please try again later."]; } + public static bool IsInvalidChatModelSettingsFailure(Exception ex) + { + foreach (var current in EnumerateExceptions(ex)) + { + if (current is AIDeploymentConfigurationException) + { + return true; + } + } + + return false; + } + private static int? TryGetClientResultStatusCode(Exception ex) { if (ex is null) @@ -127,4 +141,12 @@ private static string ExtractRetryAfterMessage(string message) return sentence.Trim(); } + + private static IEnumerable EnumerateExceptions(Exception ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + yield return current; + } + } } diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs index 8c33d30a..c6fcb0df 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/AIMemoryPreemptiveRagHandler.cs @@ -1,5 +1,6 @@ using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Tools; using CrestApps.Core.Templates.Services; using Microsoft.AspNetCore.Http; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs index 34539a78..27254515 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/DataSourcePreemptiveRagHandler.cs @@ -1,6 +1,5 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Deployments; -using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Services; diff --git a/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs b/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs index e46fefca..1d40e98e 100644 --- a/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs +++ b/src/Primitives/CrestApps.Core.AI/Handlers/PreemptiveRagOrchestrationHandler.cs @@ -1,4 +1,3 @@ -using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; using CrestApps.Core.AI.Services; diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs b/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs new file mode 100644 index 00000000..6461ca23 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Indexing/NullSearchIndexProfileStore.cs @@ -0,0 +1,78 @@ +using CrestApps.Core.Infrastructure.Indexing; +using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Models; + +namespace CrestApps.Core.AI.Indexing; + +/// +/// Fallback in-memory no-op store used when a host has not registered a persistent +/// implementation yet. +/// +public sealed class NullSearchIndexProfileStore : ISearchIndexProfileStore +{ + public ValueTask FindByIdAsync(string id) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + return ValueTask.FromResult(default); + } + + public ValueTask> GetAllAsync() + { + return ValueTask.FromResult>([]); + } + + public ValueTask> GetAsync(IEnumerable ids) + { + ArgumentNullException.ThrowIfNull(ids); + + return ValueTask.FromResult>([]); + } + + public ValueTask> PageAsync(int page, int pageSize, TQuery context) + where TQuery : QueryContext + { + ArgumentNullException.ThrowIfNull(context); + + return ValueTask.FromResult(new PageResult + { + Count = 0, + Entries = [], + }); + } + + public ValueTask DeleteAsync(SearchIndexProfile entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return ValueTask.FromResult(false); + } + + public ValueTask CreateAsync(SearchIndexProfile entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return ValueTask.CompletedTask; + } + + public ValueTask UpdateAsync(SearchIndexProfile entry) + { + ArgumentNullException.ThrowIfNull(entry); + + return ValueTask.CompletedTask; + } + + public ValueTask FindByNameAsync(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return ValueTask.FromResult(default); + } + + public Task> GetByTypeAsync(string type) + { + ArgumentException.ThrowIfNullOrEmpty(type); + + return Task.FromResult>([]); + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs new file mode 100644 index 00000000..59bc6703 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Indexing/SearchIndexProfileProvisioningService.cs @@ -0,0 +1,116 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.Infrastructure.Indexing; +using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Models; +using CrestApps.Core.Support; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Indexing; + +public sealed class SearchIndexProfileProvisioningService : ISearchIndexProfileProvisioningService +{ + private readonly ISearchIndexProfileManager _indexProfileManager; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public SearchIndexProfileProvisioningService( + ISearchIndexProfileManager indexProfileManager, + IServiceProvider serviceProvider, + ILogger logger) + { + _indexProfileManager = indexProfileManager; + _serviceProvider = serviceProvider; + _logger = logger; + } + + public async Task CreateAsync(SearchIndexProfile profile, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + + var indexManager = _serviceProvider.GetKeyedService(profile.ProviderName); + if (indexManager == null) + { + return Fail("The selected search provider is not configured for remote index provisioning.", nameof(SearchIndexProfile.ProviderName)); + } + + profile.IndexFullName = indexManager.ComposeIndexFullName(profile); + + var validationResult = await _indexProfileManager.ValidateAsync(profile); + if (!validationResult.Succeeded) + { + return validationResult; + } + + IReadOnlyCollection fields; + try + { + fields = await _indexProfileManager.GetFieldsAsync(profile, cancellationToken); + } + catch (InvalidOperationException ex) + { + return Fail(ex.Message, nameof(SearchIndexProfile.EmbeddingDeploymentId)); + } + + if (fields == null) + { + return Fail($"The index type '{profile.Type}' is not supported for remote provisioning.", nameof(SearchIndexProfile.Type)); + } + + try + { + if (await indexManager.ExistsAsync(profile, cancellationToken)) + { + return Fail($"The remote index '{profile.IndexFullName}' already exists.", nameof(SearchIndexProfile.IndexName)); + } + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to validate remote index '{IndexName}' for provider '{ProviderName}'.", + profile.IndexFullName.SanitizeForLog(), + profile.ProviderName.SanitizeForLog()); + + return Fail($"Unable to validate whether the remote index '{profile.IndexFullName}' already exists.", nameof(SearchIndexProfile.IndexName)); + } + + try + { + await indexManager.CreateAsync(profile, fields, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to create remote index '{IndexName}' for provider '{ProviderName}'.", + profile.IndexFullName.SanitizeForLog(), + profile.ProviderName.SanitizeForLog()); + + return Fail($"Unable to create the remote index '{profile.IndexFullName}'.", nameof(SearchIndexProfile.IndexName)); + } + + try + { + await _indexProfileManager.CreateAsync(profile); + } + catch + { + await indexManager.DeleteAsync(profile, cancellationToken); + + throw; + } + + await _indexProfileManager.SynchronizeAsync(profile, cancellationToken); + + return new ValidationResultDetails(); + } + + private static ValidationResultDetails Fail(string message, params string[] memberNames) + { + var result = new ValidationResultDetails(); + result.Fail(new ValidationResult(message, memberNames)); + + return result; + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Indexing/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/Indexing/ServiceCollectionExtensions.cs index 9730bf17..13c58e08 100644 --- a/src/Primitives/CrestApps.Core.AI/Indexing/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/Indexing/ServiceCollectionExtensions.cs @@ -1,4 +1,7 @@ +using CrestApps.Core.Builders; using CrestApps.Core.Infrastructure.Indexing; +using CrestApps.Core.Infrastructure.Indexing.Models; +using CrestApps.Core.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -6,6 +9,29 @@ namespace CrestApps.Core.AI.Indexing; public static class ServiceCollectionExtensions { + public static IServiceCollection AddCoreIndexingServices(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped(); + + return services; + } + + public static CrestAppsIndexingBuilder AddCoreIndexingServices(this CrestAppsIndexingBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddCoreIndexingServices(); + + return builder; + } + public static IServiceCollection AddCoreAIDataSourceIndexProfileHandler(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); diff --git a/src/Primitives/CrestApps.Core.AI/Memory/AIMemoryOptions.cs b/src/Primitives/CrestApps.Core.AI/Memory/AIMemoryOptions.cs index 4fb88c05..c9869615 100644 --- a/src/Primitives/CrestApps.Core.AI/Memory/AIMemoryOptions.cs +++ b/src/Primitives/CrestApps.Core.AI/Memory/AIMemoryOptions.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Models; +namespace CrestApps.Core.AI.Memory; public sealed class AIMemoryOptions { diff --git a/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs b/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs index e86bca57..b6ea37b7 100644 --- a/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs +++ b/src/Primitives/CrestApps.Core.AI/Memory/AIMemorySettings.cs @@ -1,4 +1,4 @@ -namespace CrestApps.Core.AI.Models; +namespace CrestApps.Core.AI.Memory; public sealed class AIMemorySettings { diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 268f923e..ba19a447 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -4,6 +4,7 @@ using CrestApps.Core.AI.Connections; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Indexing; using CrestApps.Core.AI.Memory; using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Orchestration; @@ -54,6 +55,12 @@ public static IServiceCollection AddCoreAITemplating( entry.Description = new LocalizedString(AITemplateSources.SystemPrompt, "Create a reusable system prompt template."); }); + services.TryAddScoped(); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + services.TryAddScoped>(sp => sp.GetRequiredService()); + return services; } @@ -130,6 +137,7 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi services .AddCoreAITemplating() + .AddCoreIndexingServices() .AddCoreServices() .AddOptions().Services .AddOptions().Services @@ -165,6 +173,8 @@ public static IServiceCollection AddCoreAIServices(this IServiceCollection servi sp.GetRequiredService()); } + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Transient, ConfigurationAIProviderConnectionsOptionsConfiguration>()); services.TryAddScoped(); services.TryAddScoped(); diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs index 8c44dc74..3c87548a 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIClientProviderBase.cs @@ -1,4 +1,5 @@ using CrestApps.Core.AI.Clients; +using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; using Microsoft.Extensions.AI; @@ -27,7 +28,7 @@ public ValueTask GetChatClientAsync(AIProviderConnectionEntry conne if (string.IsNullOrEmpty(deploymentName)) { - throw new ArgumentException("A deployment name must be provided, either directly or as a default in the connection settings."); + throw new AIDeploymentConfigurationException("A chat deployment name must be provided, either directly or as a default in the connection settings."); } var client = GetChatClient(connection, deploymentName); diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs new file mode 100644 index 00000000..7c82df81 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProfileFileSystemTemplateProvider.cs @@ -0,0 +1,75 @@ +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; +using CrestApps.Core.Templates.Parsing; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace CrestApps.Core.AI.Services; + +/// +/// Discovers AI profile templates from the host application's file system. +/// Scans Templates/Profiles under the application's content root. +/// +public sealed class AIProfileFileSystemTemplateProvider : IAIProfileTemplateProvider +{ + public const string ProfilesDirectoryPath = "Templates/Profiles"; + + private readonly IHostEnvironment _hostEnvironment; + private readonly IEnumerable _parsers; + private readonly ILogger _logger; + + public AIProfileFileSystemTemplateProvider( + IHostEnvironment hostEnvironment, + IEnumerable parsers, + ILogger logger) + { + _hostEnvironment = hostEnvironment; + _parsers = parsers; + _logger = logger; + } + + public Task> GetTemplatesAsync() + { + var templates = new List(); + var profilesDirectory = Path.Combine(_hostEnvironment.ContentRootPath, ProfilesDirectoryPath.Replace('/', Path.DirectorySeparatorChar)); + + if (!Directory.Exists(profilesDirectory)) + { + return Task.FromResult>(templates); + } + + foreach (var file in Directory.EnumerateFiles(profilesDirectory, "*", SearchOption.AllDirectories)) + { + var extension = Path.GetExtension(file); + var parser = AIProfileTemplateParser.GetParserForExtension(_parsers, extension); + + if (parser is null) + { + continue; + } + + try + { + var content = File.ReadAllText(file); + var parseResult = parser.Parse(content); + var relativePath = Path.GetRelativePath(profilesDirectory, file); + var id = Path.ChangeExtension(relativePath, null)? + .Replace(Path.DirectorySeparatorChar, '.') + .Replace(Path.AltDirectorySeparatorChar, '.'); + + if (string.IsNullOrWhiteSpace(id)) + { + continue; + } + + templates.Add(AIProfileTemplateParser.Parse(id, parseResult)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse AI profile template file: {FilePath}", file); + } + } + + return Task.FromResult>(templates); + } +} diff --git a/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs b/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs index 6ac3f2da..d40f4375 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/AIProfileTemplateParser.cs @@ -31,6 +31,16 @@ public static AIProfileTemplate Parse(string id, TemplateParseResult parseResult template.Source = sourceStr; } + if (string.Equals(template.Source, AITemplateSources.SystemPrompt, StringComparison.OrdinalIgnoreCase)) + { + template.Put(new SystemPromptTemplateMetadata + { + SystemMessage = parseResult.Body, + }); + + return template; + } + var profileMetadata = new ProfileTemplateMetadata { SystemMessage = parseResult.Body, diff --git a/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionService.cs b/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionService.cs index e878d80b..456356f6 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionService.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/DefaultAICompletionService.cs @@ -82,7 +82,7 @@ private async Task InvokeHandlersAsync(Func invoke) private IAICompletionClient ResolveClient(AIDeployment deployment) { var clientName = deployment.ClientName - ?? throw new InvalidOperationException($"The deployment '{deployment.Name}' does not have a client name assigned."); + ?? throw new AIDeploymentConfigurationException($"The deployment '{deployment.Name}' does not have a client name assigned."); if (!_aiOptions.Clients.TryGetValue(clientName, out var clientType)) { diff --git a/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs b/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs index 257e5c88..bdab804c 100644 --- a/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs +++ b/src/Primitives/CrestApps.Core.AI/Services/NamedAICompletionClient.cs @@ -2,6 +2,7 @@ using CrestApps.Core.AI.Clients; using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Deployments; +using CrestApps.Core.AI.Exceptions; using CrestApps.Core.AI.Models; using CrestApps.Core.Infrastructure; using CrestApps.Core.Templates.Services; @@ -97,23 +98,23 @@ public async Task CompleteAsync(IEnumerable messages, { Logger.LogWarning("Unable to chat. Unable to find a deployment and no fallback deployment could be resolved."); - return null; + throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the current request."); } if (string.IsNullOrEmpty(deployment.ModelName)) { Logger.LogWarning("Unable to chat. Unable to find a deployment name '{DeploymentName}' or the default deployment", context.ChatDeploymentName); - return null; + throw new AIDeploymentConfigurationException("The resolved chat deployment is missing a model name."); } try { - var chatClient = await BuildClientAsync(deployment, context); + var chatOptions = await GetChatOptionsAsync(context, deployment.ModelName, false); - var prompts = GetPrompts(messages, context); + var chatClient = await BuildClientAsync(deployment, context, chatOptions); - var chatOptions = await GetChatOptionsAsync(context, deployment.ModelName, false); + var prompts = GetPrompts(messages, context); var response = await chatClient.GetResponseAsync(prompts, chatOptions, cancellationToken); @@ -144,20 +145,20 @@ public async IAsyncEnumerable CompleteStreamingAsync(IEnumer { Logger.LogWarning("Unable to chat. Unable to find a deployment and no fallback deployment could be resolved."); - yield break; + throw new AIDeploymentNotFoundException("Unable to resolve a chat deployment for the current request."); } if (string.IsNullOrEmpty(deployment.ModelName)) { Logger.LogWarning("Unable to chat. Unable to find a deployment name '{DeploymentName}' or the default deployment", context.ChatDeploymentName); - yield break; + throw new AIDeploymentConfigurationException("The resolved chat deployment is missing a model name."); } - var chatClient = await BuildClientAsync(deployment, context); - var chatOptions = await GetChatOptionsAsync(context, deployment.ModelName, true); + var chatClient = await BuildClientAsync(deployment, context, chatOptions); + var prompts = GetPrompts(messages, context); await foreach (var update in chatClient.GetStreamingResponseAsync(prompts, chatOptions, cancellationToken)) @@ -230,7 +231,7 @@ private async Task GetChatOptionsAsync(AICompletionContext context, return chatOptions; } - private async ValueTask BuildClientAsync(AIDeployment deployment, AICompletionContext context) + private async ValueTask BuildClientAsync(AIDeployment deployment, AICompletionContext context, ChatOptions chatOptions) { var client = await _aIClientFactory.CreateChatClientAsync(deployment); @@ -243,7 +244,9 @@ private async ValueTask BuildClientAsync(AIDeployment deployment, A builder.UseFunctionInvocation(LoggerFactory, ConfigureFunctionInvocation); } - if (_defaultOptions.EnableDistributedCaching && context.UseCaching) + // Tool-enabled requests can carry non-serializable delegate metadata in the tool graph, + // which the distributed cache layer hashes as part of ChatOptions. + if (_defaultOptions.EnableDistributedCaching && context.UseCaching && !HasCacheUnsafeTools(chatOptions)) { builder.UseDistributedCache(_distributedCache); } @@ -255,4 +258,9 @@ private async ValueTask BuildClientAsync(AIDeployment deployment, A return builder.Build(_serviceProvider); } + + private static bool HasCacheUnsafeTools(ChatOptions chatOptions) + { + return chatOptions.Tools is { Count: > 0 }; + } } diff --git a/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs index c6d41566..7321aad2 100644 --- a/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.Templates/Extensions/ServiceCollectionExtensions.cs @@ -38,6 +38,7 @@ public static IServiceCollection AddTemplating( services.TryAddEnumerable(ServiceDescriptor.Singleton()); services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); if (configure != null) { @@ -48,7 +49,7 @@ public static IServiceCollection AddTemplating( } /// - /// Registers an assembly's embedded Templates/Prompts/*.md resources as templates. + /// Registers an assembly's embedded Templates/*.md resources as templates. /// /// The service collection. /// The assembly containing embedded template resources. diff --git a/src/Primitives/CrestApps.Core.Templates/Models/Template.cs b/src/Primitives/CrestApps.Core.Templates/Models/Template.cs index dc2119cf..4c34db28 100644 --- a/src/Primitives/CrestApps.Core.Templates/Models/Template.cs +++ b/src/Primitives/CrestApps.Core.Templates/Models/Template.cs @@ -22,6 +22,12 @@ public sealed class Template /// public string Content { get; set; } + /// + /// Gets or sets the semantic kind of template (for example, SystemPrompt or Profile). + /// This is separate from , which identifies where the template came from. + /// + public string Kind { get; set; } + /// /// Gets or sets the source identifier (e.g., assembly name, module name, or "code"). /// diff --git a/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs b/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs index e73d7d4b..f962be78 100644 --- a/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs +++ b/src/Primitives/CrestApps.Core.Templates/Providers/EmbeddedResourceTemplateProvider.cs @@ -6,15 +6,12 @@ namespace CrestApps.Core.Templates.Providers; /// /// Discovers templates from embedded resources in a specified assembly. -/// Looks for resources matching the pattern *.Templates.Prompts.* +/// Looks for resources matching the pattern *.Templates.* /// with extensions supported by registered parsers. /// public sealed class EmbeddedResourceTemplateProvider : ITemplateProvider { - private const string PromptsResourceSegment = ".Templates.Prompts."; - - // OrchardCore Module Targets use '>' as the path separator in embedded resource logical names. - private const string OrchardCorePromptsResourceSegment = ".Templates>Prompts>"; + private const string TemplatesResourceSegment = ".Templates."; private readonly Assembly _assembly; private readonly IEnumerable _parsers; @@ -40,16 +37,9 @@ public Task> GetTemplatesAsync() foreach (var resourceName in resourceNames) { - var promptsIndex = resourceName.IndexOf(PromptsResourceSegment, StringComparison.OrdinalIgnoreCase); - var segmentLength = PromptsResourceSegment.Length; - - if (promptsIndex < 0) - { - promptsIndex = resourceName.IndexOf(OrchardCorePromptsResourceSegment, StringComparison.OrdinalIgnoreCase); - segmentLength = OrchardCorePromptsResourceSegment.Length; - } + var templatesIndex = resourceName.IndexOf(TemplatesResourceSegment, StringComparison.OrdinalIgnoreCase); - if (promptsIndex < 0) + if (templatesIndex < 0) { continue; } @@ -74,27 +64,19 @@ public Task> GetTemplatesAsync() var content = reader.ReadToEnd(); var parseResult = parser.Parse(content); - // Extract the filename portion from the resource name. - var afterPrompts = resourceName[(promptsIndex + segmentLength)..]; + var afterTemplates = resourceName[(templatesIndex + TemplatesResourceSegment.Length)..]; + var relativePath = TemplateProviderConventions.ResolveEmbeddedTemplateId(afterTemplates, out var defaultKind); - // Remove the file extension. - var id = extension != null && afterPrompts.EndsWith(extension, StringComparison.OrdinalIgnoreCase) - ? afterPrompts[..^extension.Length] - : afterPrompts; + var id = extension != null && relativePath.EndsWith(extension, StringComparison.OrdinalIgnoreCase) + ? relativePath[..^extension.Length] + : relativePath; - var template = new Template - { - Id = id, - Metadata = parseResult.Metadata, - Content = parseResult.Body, - Source = _source, - FeatureId = _featureId, - }; - - if (string.IsNullOrWhiteSpace(template.Metadata.Title)) - { - template.Metadata.Title = id.Replace('-', ' ').Replace('.', ' '); - } + var template = TemplateProviderConventions.CreateTemplate( + id, + parseResult, + _source, + _featureId, + defaultKind); templates.Add(template); } diff --git a/src/Primitives/CrestApps.Core.Templates/Providers/FileSystemTemplateProvider.cs b/src/Primitives/CrestApps.Core.Templates/Providers/FileSystemTemplateProvider.cs index fc1ab6fd..91d1afd0 100644 --- a/src/Primitives/CrestApps.Core.Templates/Providers/FileSystemTemplateProvider.cs +++ b/src/Primitives/CrestApps.Core.Templates/Providers/FileSystemTemplateProvider.cs @@ -7,14 +7,15 @@ namespace CrestApps.Core.Templates.Providers; /// /// Discovers templates from the file system. -/// Scans configured paths for Templates/Prompts/ files matching registered parser extensions. +/// Scans configured paths for templates stored directly under Templates/. +/// Subdirectories are ignored so other providers can own their own folder conventions. /// public sealed class FileSystemTemplateProvider : ITemplateProvider { /// - /// The directory name within a project where prompt templates are stored. + /// The directory name within a project where generic templates are stored. /// - public const string PromptsDirectoryPath = "Templates/Prompts"; + public const string TemplatesDirectoryPath = "Templates"; private readonly TemplateOptions _options; private readonly IEnumerable _parsers; @@ -36,30 +37,22 @@ public Task> GetTemplatesAsync() foreach (var basePath in _options.DiscoveryPaths) { - var promptsDir = Path.Combine(basePath, PromptsDirectoryPath.Replace('/', Path.DirectorySeparatorChar)); + var templatesDir = Path.Combine(basePath, TemplatesDirectoryPath.Replace('/', Path.DirectorySeparatorChar)); - if (!Directory.Exists(promptsDir)) + if (!Directory.Exists(templatesDir)) { continue; } - DiscoverTemplates(promptsDir, featureId: null, basePath, templates); - - // Scan subdirectories for feature-specific prompts. - - foreach (var subDir in Directory.GetDirectories(promptsDir)) - { - var featureId = Path.GetFileName(subDir); - DiscoverTemplates(subDir, featureId, basePath, templates); - } + DiscoverTemplates(templatesDir, basePath, templates); } return Task.FromResult>(templates); } - private void DiscoverTemplates(string directory, string featureId, string sourcePath, List