From d1f6cc6080eff0e15428569fff4106d4e413bcdf Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 16 Apr 2026 09:41:32 -0700 Subject: [PATCH 1/3] Add Anthropic Claude --- CrestApps.Core.slnx | 1 + Directory.Packages.props | 1 + README.md | 1 + .../docs/changelog/v1.0.0.md | 3 +- src/CrestApps.Core.Docs/docs/core/chat.md | 26 ++- src/CrestApps.Core.Docs/docs/core/claude.md | 115 +++++++++++ src/CrestApps.Core.Docs/docs/core/copilot.md | 6 +- src/CrestApps.Core.Docs/docs/core/index.md | 3 +- .../docs/core/mvc-example.md | 7 +- .../CrestApps.Core.AI.Claude.csproj | 23 +++ .../ClaudeChatInteractionSettingsHandler.cs | 41 ++++ .../ClaudeOrchestrationContextHandler.cs | 28 +++ .../Models/ClaudeModelInfo.cs | 27 +++ .../Models/ClaudeOptions.cs | 22 ++ .../Models/ClaudeSessionMetadata.cs | 13 ++ .../ServiceCollectionExtensions.cs | 40 ++++ .../Services/ClaudeClientService.cs | 91 +++++++++ .../Services/ClaudeOrchestrator.cs | 191 ++++++++++++++++++ .../ServiceCollectionExtensions.cs | 2 +- .../ServiceCollectionExtensions.cs | 2 +- .../AI/Controllers/AIProfileController.cs | 33 ++- .../AI/Controllers/AITemplateController.cs | 24 ++- .../Areas/AI/ViewModels/AIProfileViewModel.cs | 28 +++ .../AI/ViewModels/AITemplateViewModel.cs | 29 +++ .../Areas/AI/Views/AIProfile/Create.cshtml | 6 + .../Areas/AI/Views/AIProfile/Edit.cshtml | 6 + .../Areas/AI/Views/AITemplate/Create.cshtml | 7 + .../Areas/AI/Views/AITemplate/Edit.cshtml | 7 + .../AIChat/Models/ClaudeAuthenticationType.cs | 7 + .../Areas/AIChat/Models/ClaudeSettings.cs | 12 ++ .../Services/ClaudeModelSelectListFactory.cs | 37 ++++ .../Services/ClaudeSettingsExtensions.cs | 25 +++ .../Services/MvcClaudeOptionsConfiguration.cs | 58 ++++++ .../Areas/AIChat/Views/AIChat/Chat.cshtml | 38 +++- .../Areas/AIChat/Views/AIChat/Test.cshtml | 38 +++- .../AIChat/Views/Shared/_ChatWidget.cshtml | 15 +- .../Admin/Controllers/SettingsController.cs | 57 +++++- .../Admin/ViewModels/SettingsViewModel.cs | 16 ++ .../Areas/Admin/Views/Settings/Index.cshtml | 83 +++++++- .../Controllers/ChatInteractionController.cs | 54 +++++ .../ChatInteractionChatViewModel.cs | 8 + .../ViewModels/ChatInteractionViewModel.cs | 8 + .../Views/ChatInteraction/Chat.cshtml | 52 ++++- .../Views/ChatInteraction/Create.cshtml | 6 + .../CrestApps.Core.Mvc.Web.csproj | 1 + src/Startup/CrestApps.Core.Mvc.Web/Program.cs | 3 + .../Services/SiteSettingsStore.cs | 1 + .../Views/Shared/_ClaudeConfig.cshtml | 77 +++++++ .../Views/Shared/_CopilotConfig.cshtml | 5 +- .../Views/Shared/_Layout.cshtml | 1 + .../CrestApps.Core.Tests.csproj | 1 + .../MvcAITemplateViewModelClaudeTests.cs | 69 +++++++ .../MvcClaudeSettingsExtensionsTests.cs | 67 ++++++ .../Mvc/AIProfileViewModelClaudeTests.cs | 84 ++++++++ 54 files changed, 1559 insertions(+), 47 deletions(-) create mode 100644 src/CrestApps.Core.Docs/docs/core/claude.md create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/CrestApps.Core.AI.Claude.csproj create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeModelInfo.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeOptions.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeSessionMetadata.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeClientService.cs create mode 100644 src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeAuthenticationType.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeSettings.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeModelSelectListFactory.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeSettingsExtensions.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcClaudeOptionsConfiguration.cs create mode 100644 src/Startup/CrestApps.Core.Mvc.Web/Views/Shared/_ClaudeConfig.cshtml create mode 100644 tests/CrestApps.Core.Tests/Framework/Chat.Claude/MvcAITemplateViewModelClaudeTests.cs create mode 100644 tests/CrestApps.Core.Tests/Framework/Chat.Claude/MvcClaudeSettingsExtensionsTests.cs create mode 100644 tests/CrestApps.Core.Tests/Framework/Mvc/AIProfileViewModelClaudeTests.cs diff --git a/CrestApps.Core.slnx b/CrestApps.Core.slnx index a61999b6..7098a517 100644 --- a/CrestApps.Core.slnx +++ b/CrestApps.Core.slnx @@ -21,6 +21,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index fa5839fc..5c079e69 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,6 +26,7 @@ + diff --git a/README.md b/README.md index 625e3053..864bcded 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Most teams start AI integration with a provider SDK, then quickly run into the r - **MCP server and MCP client capabilities** including resource, prompt, and external host management - **Custom AI functions** for application-specific actions and tool calling - **Copilot orchestrator integration** +- **Claude orchestrator integration** - **Template-driven prompts and profile definitions** for cleaner code and reusable system behavior - **Chat widgets and business workflows** including chat metrics, data extraction, goal conversion, post-session processing, and live-agent handoff - **Usage and lead reporting** for AI consumption and customer engagement scenarios 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 2a3ef0e4..d9c2b770 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/v1.0.0.md @@ -29,7 +29,8 @@ description: Initial standalone release notes for the CrestApps.Core repository. - centralizes reusable MCP runtime registration in `AddCoreAIMcpServices()`, moves the shared MCP metadata, capability-resolution, tool-registry, SSE settings-handler, and invoke-function services into `CrestApps.Core.AI.Mcp`, and splits optional StdIO transport registration so hosts can enable it only where needed - 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 button on assistant messages in the AI Chat and Chat Interaction UIs when a TTS deployment is configured, allowing users to hear any message read aloud regardless of whether the profile uses Conversation mode +- 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 +- 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 diff --git a/src/CrestApps.Core.Docs/docs/core/chat.md b/src/CrestApps.Core.Docs/docs/core/chat.md index d07aa6a3..d8b2ba24 100644 --- a/src/CrestApps.Core.Docs/docs/core/chat.md +++ b/src/CrestApps.Core.Docs/docs/core/chat.md @@ -762,10 +762,32 @@ Both widgets require the following libraries to be loaded on the page **before** |---------|---------|-------------| | [Chart.js](https://www.chartjs.org/) | Interactive chart rendering | `https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js` | | [highlight.js](https://highlightjs.org/) | Code syntax highlighting | `https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js` | +| [Font Awesome 7](https://fontawesome.com/) | Message action icons and brand icons such as Claude | `https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@7/css/all.min.css` | + +### Widget layout behavior + +The AI Chat widget now supports draggable and resizable floating shells through the shared `widget` config. These behaviors are enabled by default for widgets, persist the user-selected position and size in `localStorage`, and can be turned off per host if needed. + +```js +window.openAIChatManager.initialize({ + // ... other config ... + widget: { + chatWidgetContainer: '#widget-panel', + chatWidgetStateName: 'support-chat', + toggleButtonSelector: '#widget-toggle', + resetSizeButtonSelector: '#widget-reset-size', + enableDragging: true, + enableResizing: true, + persistLayout: true + } +}); +``` + +When `enableDragging` is on, both the widget window and its floating toggle button can be repositioned. When `enableResizing` is on, the widget can be resized from the browser resize handle and the optional reset-size button can restore the default dimensions. Set either flag to `false` to opt out for a specific host. ### Text-to-Speech Play Icon -When a text-to-speech (TTS) deployment is configured, each assistant message shows a play icon that reads the message aloud via server-side speech synthesis. Clicking the icon calls `SynthesizeSpeech` on the SignalR hub, which streams audio chunks back to the client and plays them as MP3. Clicking the icon again pauses playback. +When a text-to-speech (TTS) deployment is configured, each completed assistant message shows a play icon that reads the message aloud via server-side speech synthesis. Clicking the icon calls `SynthesizeSpeech` on the SignalR hub, which streams audio chunks back to the client and plays them as MP3. While audio is active, the icon switches to pause, the action buttons stay anchored at the bottom-right of the message just above the divider, and starting playback on a different message automatically stops the current player. To enable this feature, pass `textToSpeechEnabled: true` in the widget initialization config: @@ -777,5 +799,5 @@ window.openAIChatManager.initialize({ }); ``` -The play icon appears on all completed assistant messages regardless of whether the profile uses Conversation mode. The only requirement is that a TTS deployment is available on the server. +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/claude.md b/src/CrestApps.Core.Docs/docs/core/claude.md new file mode 100644 index 00000000..73825464 --- /dev/null +++ b/src/CrestApps.Core.Docs/docs/core/claude.md @@ -0,0 +1,115 @@ +--- +sidebar_label: Claude +sidebar_position: 17 +title: Claude Orchestrator +description: Configure Claude as a first-class CrestApps.Core orchestrator using the official Anthropic C# SDK and the MVC sample host. +--- + +# Claude Orchestrator + +> A Claude-powered `IOrchestrator` implementation built on the official `Anthropic` NuGet package. It runs entirely through managed SDK dependencies, so hosts do not need to install any external executable or CLI. + +## Quick start + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddClaudeOrchestrator() + ) +); +``` + +Or register the lower-level service directly: + +```csharp +builder.Services.AddCoreAIClaudeOrchestrator(); +``` + +Resolve it by name: + +```csharp +public sealed class MyController(IOrchestratorResolver resolver) +{ + public async IAsyncEnumerable StreamAsync(OrchestrationContext context) + { + var orchestrator = resolver.Resolve("anthropic"); + + await foreach (var update in orchestrator.ExecuteStreamingAsync(context)) + { + yield return update.Text; + } + } +} +``` + +## What it adds + +The `CrestApps.Core.AI.Claude` package mirrors the Copilot integration points that matter for host applications: + +- registers a named orchestrator (`"anthropic"`) with the title **Claude** +- adds an orchestration-context handler that reads `ClaudeSessionMetadata` +- adds a chat-interaction settings handler so SignalR/MVC chat settings persist Claude model overrides +- exposes `ClaudeClientService` for live model discovery through the Anthropic Models API +- keeps all dependencies inside normal NuGet restore and application publish flows + +Unlike Copilot, Claude does **not** use GitHub OAuth, a credential store contract, or build-transitive CLI download targets. + +## Configuration + +### `ClaudeOptions` + +```csharp +public sealed class ClaudeOptions +{ + public string ApiKey { get; set; } + public string BaseUrl { get; set; } = "https://api.anthropic.com"; + public string DefaultModel { get; set; } +} +``` + +### `appsettings.json` + +```json +{ + "ClaudeOptions": { + "ApiKey": "sk-ant-...", + "BaseUrl": "https://api.anthropic.com", + "DefaultModel": "claude-sonnet-4-6" + } +} +``` + +The orchestrator requires an API key. A default model is optional when profiles, templates, or chat interactions provide their own Claude model override. + +Per-profile, per-template, and per-chat overrides are stored in `ClaudeSessionMetadata`. + +## Services registered by `AddCoreAIClaudeOrchestrator()` + +| Service | Implementation | Lifetime | Purpose | +| --- | --- | --- | --- | +| `IOrchestrator` | `ClaudeOrchestrator` (name: `"anthropic"`) | Scoped | Streams Claude responses through the shared orchestration pipeline | +| `ClaudeClientService` | `ClaudeClientService` | Scoped | Creates configured SDK clients and lists available models | +| `IChatInteractionSettingsHandler` | `ClaudeChatInteractionSettingsHandler` | Scoped | Persists Anthropic-specific chat settings | +| `IOrchestrationContextBuilderHandler` | `ClaudeOrchestrationContextHandler` | Scoped | Pushes Anthropic metadata into orchestration context | + +## MVC sample host + +`CrestApps.Core.Mvc.Web` registers the Claude orchestrator by default: + +```csharp +builder.Services.AddCrestAppsCore(crestApps => crestApps + .AddAISuite(ai => ai + .AddClaudeOrchestrator() + .AddCopilotOrchestrator() + ) +); +``` + +The MVC sample also wires Claude into: + +1. **Admin → Settings** for Claude authentication mode, API key, base URL, and the optional default model +2. **AI Profiles** for profile-level model overrides +3. **AI Templates** for profile-source template overrides +4. **Chat Interactions** for interactive Claude model selection + +When the API key is configured, the MVC editors load available Claude models from the Anthropic Models API. If model discovery is unavailable, the same editors fall back to free-form model entry. diff --git a/src/CrestApps.Core.Docs/docs/core/copilot.md b/src/CrestApps.Core.Docs/docs/core/copilot.md index 36ff4be6..15f46f78 100644 --- a/src/CrestApps.Core.Docs/docs/core/copilot.md +++ b/src/CrestApps.Core.Docs/docs/core/copilot.md @@ -1,11 +1,11 @@ --- -sidebar_label: GitHub Copilot +sidebar_label: Copilot sidebar_position: 16 -title: GitHub Copilot Orchestrator +title: Copilot Orchestrator description: An alternative IOrchestrator implementation that uses the GitHub Copilot Extensions SDK for AI completions, supporting GitHub OAuth and Bring Your Own Key (BYOK) authentication modes. --- -# GitHub Copilot Orchestrator +# Copilot Orchestrator > An alternative orchestrator that uses the GitHub Copilot Extensions SDK instead of the default orchestration pipeline, supporting both GitHub OAuth and BYOK authentication modes. diff --git a/src/CrestApps.Core.Docs/docs/core/index.md b/src/CrestApps.Core.Docs/docs/core/index.md index a7118b73..bf3cea2f 100644 --- a/src/CrestApps.Core.Docs/docs/core/index.md +++ b/src/CrestApps.Core.Docs/docs/core/index.md @@ -78,7 +78,8 @@ The quickest way to validate the setup is to use **Chat Interactions** first, th | Templates | `AddTemplating()` | `CrestApps.Core.Templates` | [AI Templates](./ai-templates.md) | | Custom tools | `AddCoreAITool()` | `CrestApps.Core.AI` | [Custom AI Tools](./tools.md) | | Agents | Agent and orchestration registrations | `CrestApps.Core.AI` | [AI Agents](./agents.md) | -| Copilot orchestration | `AddCoreAICopilotOrchestrator()` | `CrestApps.Core.AI.Copilot` | [GitHub Copilot Orchestrator](./copilot.md) | +| Copilot orchestration | `AddCoreAICopilotOrchestrator()` | `CrestApps.Core.AI.Copilot` | [Copilot Orchestrator](./copilot.md) | +| Claude orchestration | `AddCoreAIClaudeOrchestrator()` | `CrestApps.Core.AI.Claude` | [Claude Orchestrator](./claude.md) | | SignalR and widgets | `AddCoreSignalR()` | `CrestApps.Core.SignalR` | [SignalR](./signalr.md) | | Data storage | Store registration extensions | `CrestApps.Core.Data.YesSql` | [Data Storage](./data-storage.md) | | Providers | Provider-specific extensions | Provider packages | [AI Providers](../providers/index.md) | diff --git a/src/CrestApps.Core.Docs/docs/core/mvc-example.md b/src/CrestApps.Core.Docs/docs/core/mvc-example.md index a02dc9a6..619f93f8 100644 --- a/src/CrestApps.Core.Docs/docs/core/mvc-example.md +++ b/src/CrestApps.Core.Docs/docs/core/mvc-example.md @@ -94,6 +94,7 @@ The core framework registration chain: builder.Services.AddCrestAppsCore(crestApps => crestApps .AddAISuite(ai => ai .AddMarkdown() + .AddClaudeOrchestrator() .AddCopilotOrchestrator() .AddChatInteractions(chat => chat.ConfigureChatHubOptions()) .AddDocumentProcessing(documentProcessing => documentProcessing @@ -134,6 +135,8 @@ builder.Services.AddCrestAppsCore(crestApps => crestApps `AddAISuite(...)` always wires the shared foundation, AI runtime, and orchestration together. `AddChatInteractions()` inside that suite then registers the shared `DataSourceChatInteractionSettingsHandler`, so Chat Interactions persist the selected data source and RAG metadata through the framework settings pipeline instead of MVC-only wiring. The provider service blocks also pull in the shared data-source RAG registrations, which register both `DataSourceOrchestrationHandler` and `DataSourcePreemptiveRagHandler` at the framework level so source availability instructions and preemptive RAG stay aligned with the saved chat settings. +The MVC sample now also registers both the **Claude** and **Copilot** orchestrators. Claude uses the official Anthropic SDK with a site-level authentication mode, API key, and live model discovery, while Copilot keeps its dedicated OAuth/BYOK flow. Admins can choose either orchestrator from the same AI Profile, AI Template, and Chat Interaction editors. + Documents, memory, and data sources now remain fully independent orchestration sources in the shared framework. Each source injects its own availability instructions and preemptive-RAG context, so the orchestrator can compose them together without the document prompts needing to know whether memory or data sources are also attached. The MVC sample explicitly calls `AddMarkdown()` inside `AddAISuite(...)`. That keeps Markdown-aware normalization opt-in at the host level instead of making `CrestApps.Core.AI` depend on the Markdig-backed package automatically. @@ -192,7 +195,6 @@ Provider-grouped connection settings under `CrestApps:Providers:{ProviderName}:C "ProviderName": "AzureSpeech", "Name": "whisper", "Type": "SpeechToText", - "IsDefault": true, "Endpoint": "https://eastus.stt.speech.microsoft.com", "AuthenticationType": "ApiKey", "ApiKey": "YOUR_API_KEY" @@ -201,7 +203,6 @@ Provider-grouped connection settings under `CrestApps:Providers:{ProviderName}:C "ProviderName": "AzureSpeech", "Name": "AzureTextToSpeech", "Type": "TextToSpeech", - "IsDefault": true, "Endpoint": "https://eastus.tts.speech.microsoft.com", "AuthenticationType": "ApiKey", "ApiKey": "YOUR_API_KEY" @@ -262,7 +263,7 @@ If an administrator already deleted the remote index directly in Elasticsearch o -The MVC admin chat widget now stays bound to the configured admin-chat profile instead of exposing a profile picker, restores its open/closed state and active session across page navigation, and reuses the stored session automatically when the next admin page loads. **Settings → AI Settings** now includes an **Admin widget** card where administrators choose that profile; leaving it empty disables the widget entirely. The same card also lets administrators change the widget accent color, which now defaults to the admin theme secondary color (`#6c757d`) instead of a hard-coded green. The widget now boots a real chat session immediately, so profiles with an **Initial prompt** show that assistant message first; otherwise it falls back to the welcome message and then **What do you want to know?** when no welcome text is configured. +The MVC admin chat widget now stays bound to the configured admin-chat profile instead of exposing a profile picker, restores its open/closed state and active session across page navigation, and reuses the stored session automatically when the next admin page loads. **Settings → AI Settings** now includes an **Admin widget** card where administrators choose that profile; leaving it empty disables the widget entirely. The same card also lets administrators change the widget accent color, which now defaults to the admin theme secondary color (`#6c757d`) instead of a hard-coded green. The widget now boots a real chat session immediately, so profiles with an **Initial prompt** show that assistant message first; otherwise it falls back to the welcome message and then **What do you want to know?** when no welcome text is configured. The shared widget runtime also now lets users drag both the floating toggle button and the widget shell, resize the widget, restore the default size from the header, and persist that layout in browser storage unless a host opts out through the widget config. The MVC sample also now records provider usage in a dedicated **AI Usage Analytics** report. The report groups tracked completion calls by authenticated username or **Anonymous**, then breaks usage down by completion client and resolved model/deployment while showing total calls, distinct sessions, distinct chat interactions, token totals, and average latency. Session analytics now keep token totals and user-visible response latency separate so the main chat analytics page still shows per-session performance while the usage report captures provider activity more directly. diff --git a/src/Primitives/CrestApps.Core.AI.Claude/CrestApps.Core.AI.Claude.csproj b/src/Primitives/CrestApps.Core.AI.Claude/CrestApps.Core.AI.Claude.csproj new file mode 100644 index 00000000..f4da1915 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/CrestApps.Core.AI.Claude.csproj @@ -0,0 +1,23 @@ + + + + CrestApps.Core.AI.Claude + CrestApps AI Anthropic + + $(CrestAppsDescription) + + Anthropic SDK-based orchestrator for Claude chat sessions. + + $(PackageTags) ai anthropic claude orchestration + + + + + + + + + + + + diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs new file mode 100644 index 00000000..690b0bc9 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeChatInteractionSettingsHandler.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Models; + +namespace CrestApps.Core.AI.Claude.Handlers; + +internal sealed class ClaudeChatInteractionSettingsHandler : IChatInteractionSettingsHandler +{ + public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings) + { + var orchestratorName = GetString(settings, "orchestratorName") ?? interaction.OrchestratorName; + if (!string.Equals(orchestratorName, Services.ClaudeOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) + { + interaction.Remove(); + return Task.CompletedTask; + } + + interaction.Alter(metadata => + { + metadata.ClaudeModel = GetString(settings, "anthropicModel"); + }); + + return Task.CompletedTask; + } + + public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings) + { + return Task.CompletedTask; + } + + private static string GetString(JsonElement element, string propertyName) + { + if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String) + { + return prop.GetString(); + } + + return null; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs new file mode 100644 index 00000000..61a6d8d4 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Handlers/ClaudeOrchestrationContextHandler.cs @@ -0,0 +1,28 @@ +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Orchestration; + +namespace CrestApps.Core.AI.Claude.Handlers; + +internal sealed class ClaudeOrchestrationContextHandler : IOrchestrationContextBuilderHandler +{ + public Task BuildingAsync(OrchestrationContextBuildingContext context) + { + if (context.Resource is not ExtensibleEntity entity) + { + return Task.CompletedTask; + } + + if (entity.TryGet(out var metadata)) + { + context.Context.Properties[nameof(ClaudeSessionMetadata)] = metadata; + } + + return Task.CompletedTask; + } + + public Task BuiltAsync(OrchestrationContextBuiltContext context) + { + return Task.CompletedTask; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeModelInfo.cs b/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeModelInfo.cs new file mode 100644 index 00000000..c9be7415 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeModelInfo.cs @@ -0,0 +1,27 @@ +namespace CrestApps.Core.AI.Claude.Models; + +/// +/// Represents an Anthropic model available to the configured API key. +/// +public sealed class ClaudeModelInfo +{ + /// + /// The Anthropic model identifier. + /// + public string Id { get; set; } + + /// + /// The user-facing model name. + /// + public string Name { get; set; } + + /// + /// The maximum supported input token count when reported by the API. + /// + public long? MaxInputTokens { get; set; } + + /// + /// The maximum supported output token count when reported by the API. + /// + public long? MaxOutputTokens { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeOptions.cs b/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeOptions.cs new file mode 100644 index 00000000..2a82f0bd --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeOptions.cs @@ -0,0 +1,22 @@ +namespace CrestApps.Core.AI.Claude.Models; + +/// +/// Options for configuring the Anthropic orchestrator. +/// +public sealed class ClaudeOptions +{ + /// + /// The Anthropic API key. + /// + public string ApiKey { get; set; } + + /// + /// The Anthropic API base URL. + /// + public string BaseUrl { get; set; } = "https://api.anthropic.com"; + + /// + /// The default model identifier used when a session does not override it. + /// + public string DefaultModel { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeSessionMetadata.cs b/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeSessionMetadata.cs new file mode 100644 index 00000000..8a3772b1 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Models/ClaudeSessionMetadata.cs @@ -0,0 +1,13 @@ +namespace CrestApps.Core.AI.Claude.Models; + +/// +/// Metadata specific to Anthropic orchestrator sessions. +/// Stored on and . +/// +public sealed class ClaudeSessionMetadata +{ + /// + /// The Anthropic model override for the session. + /// + public string ClaudeModel { get; set; } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..d736d3c4 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/ServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using CrestApps.Core.AI.Claude.Handlers; +using CrestApps.Core.AI.Claude.Services; +using CrestApps.Core.AI.Chat; +using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.Builders; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CrestApps.Core.AI.Claude; + +public static class ServiceCollectionExtensions +{ + /// + /// Adds the Anthropic orchestrator and related services. + /// + public static IServiceCollection AddCoreAIClaudeOrchestrator(this IServiceCollection services) + { + services.AddOrchestrator(ClaudeOrchestrator.OrchestratorName) + .WithTitle("Claude"); + + services.TryAddScoped(); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + services.TryAddEnumerable(ServiceDescriptor.Scoped()); + + return services; + } + + public static CrestAppsAISuiteBuilder AddClaudeOrchestrator(this CrestAppsAISuiteBuilder builder) + { + builder.Services.AddCoreAIClaudeOrchestrator(); + return builder; + } + + [Obsolete("Use AddAISuite(ai => ai.AddClaudeOrchestrator()).")] + public static CrestAppsCoreBuilder AddClaudeOrchestrator(this CrestAppsCoreBuilder builder) + { + builder.Services.AddCoreAIClaudeOrchestrator(); + return builder; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeClientService.cs b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeClientService.cs new file mode 100644 index 00000000..9cc90c2a --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeClientService.cs @@ -0,0 +1,91 @@ +using Anthropic; +using Anthropic.Core; +using CrestApps.Core.AI.Claude.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Claude.Services; + +/// +/// Creates configured Anthropic SDK clients and exposes model discovery helpers. +/// +public sealed class ClaudeClientService +{ + private readonly ClaudeOptions _options; + private readonly ILogger _logger; + + public ClaudeClientService( + IOptions options, + ILogger logger) + { + _options = options.Value; + _logger = logger; + } + + public AnthropicClient CreateClient() + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + throw new InvalidOperationException("Claude is not configured. Please configure an API key."); + } + + var clientOptions = new ClientOptions + { + ApiKey = _options.ApiKey, + }; + + if (!string.IsNullOrWhiteSpace(_options.BaseUrl)) + { + clientOptions.BaseUrl = _options.BaseUrl; + } + + return new AnthropicClient(clientOptions); + } + + public async Task> ListModelsAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.ApiKey)) + { + return []; + } + + try + { + using var client = CreateClient(); + + var page = await client.Models.List(cancellationToken: cancellationToken); + var models = new List(); + + while (page is not null) + { + models.AddRange(page.Items + .Where(model => !string.IsNullOrWhiteSpace(model.ID)) + .Select(model => new ClaudeModelInfo + { + Id = model.ID, + Name = string.IsNullOrWhiteSpace(model.DisplayName) ? model.ID : model.DisplayName, + MaxInputTokens = model.MaxInputTokens, + MaxOutputTokens = model.MaxTokens, + })); + + if (!page.HasNext()) + { + break; + } + + page = await page.Next(cancellationToken); + } + + return models + .GroupBy(model => model.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error listing Claude models."); + return []; + } + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs new file mode 100644 index 00000000..d5be9ac1 --- /dev/null +++ b/src/Primitives/CrestApps.Core.AI.Claude/Services/ClaudeOrchestrator.cs @@ -0,0 +1,191 @@ +using System.Runtime.CompilerServices; +using Anthropic; +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Handlers; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Orchestration; +using CrestApps.Core.AI.Tooling; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.AI.Claude.Services; + +/// +/// Anthropic-powered orchestrator that runs Claude directly through the official C# SDK. +/// +public sealed class ClaudeOrchestrator : IOrchestrator +{ + public const string OrchestratorName = "anthropic"; + + private readonly IToolRegistry _toolRegistry; + private readonly ClaudeClientService _clientService; + private readonly ClaudeOptions _anthropicOptions; + private readonly DefaultAIOptions _defaultOptions; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger _logger; + + public ClaudeOrchestrator( + IToolRegistry toolRegistry, + ClaudeClientService clientService, + IOptions anthropicOptions, + IOptions defaultOptions, + ILoggerFactory loggerFactory, + ILogger logger) + { + _toolRegistry = toolRegistry; + _clientService = clientService; + _anthropicOptions = anthropicOptions.Value; + _defaultOptions = defaultOptions.Value; + _loggerFactory = loggerFactory; + _logger = logger; + } + + public string Name => OrchestratorName; + + public async IAsyncEnumerable ExecuteStreamingAsync( + OrchestrationContext context, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(context.CompletionContext); + + context.SourceName ??= Name; + + ClaudeSessionMetadata metadata = null; + if (context.Properties.TryGetValue(nameof(ClaudeSessionMetadata), out var metadataValue)) + { + metadata = metadataValue as ClaudeSessionMetadata; + } + + var modelId = !string.IsNullOrWhiteSpace(metadata?.ClaudeModel) + ? metadata.ClaudeModel + : null; + modelId ??= _anthropicOptions.DefaultModel; + + if (!IsConfigured(_anthropicOptions)) + { + yield return CreateTextResponse("Claude is not configured and cannot be used until it has been configured."); + yield break; + } + + if (string.IsNullOrWhiteSpace(modelId)) + { + yield return CreateTextResponse("Claude is configured, but no default model or override has been selected."); + yield break; + } + + var scopedEntries = await _toolRegistry.GetAllAsync(context.CompletionContext, cancellationToken); + context.CompletionContext.ToolNames = scopedEntries.Select(entry => entry.Name).ToArray(); + context.CompletionContext.AdditionalProperties[FunctionInvocationAICompletionServiceHandler.ScopedEntriesKey] = scopedEntries; + + using var client = _clientService.CreateClient(); + var chatClient = client.AsIChatClient(modelId, context.CompletionContext.MaxTokens ?? _defaultOptions.MaxOutputTokens); + + var builder = new ChatClientBuilder(chatClient); + builder.UseLogging(_loggerFactory); + builder.UseFunctionInvocation(_loggerFactory, invocation => + { + invocation.MaximumIterationsPerRequest = _defaultOptions.MaximumIterationsPerRequest; + }); + + var configuredClient = builder.Build(context.ServiceProvider); + var chatOptions = BuildChatOptions(context.CompletionContext, modelId); + var prompts = BuildPrompts(context); + ChatResponseUpdate errorResponse = null; + + await using var responseEnumerator = configuredClient + .GetStreamingResponseAsync(prompts, chatOptions, cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) + { + ChatResponseUpdate update; + + try + { + if (!await responseEnumerator.MoveNextAsync()) + { + break; + } + + update = responseEnumerator.Current; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "ClaudeOrchestrator: Unexpected error during Anthropic session."); + errorResponse = CreateTextResponse("An unexpected error occurred while communicating with Anthropic. Please try again."); + break; + } + + yield return update; + } + + if (errorResponse is not null) + { + yield return errorResponse; + } + } + + private static bool IsConfigured(ClaudeOptions options) + { + return options is not null && + !string.IsNullOrWhiteSpace(options.ApiKey) && + !string.IsNullOrWhiteSpace(options.DefaultModel); + } + + private static ChatOptions BuildChatOptions(AICompletionContext context, string modelId) + { + return new ChatOptions + { + ModelId = modelId, + Temperature = context.Temperature, + TopP = context.TopP, + FrequencyPenalty = context.FrequencyPenalty, + PresencePenalty = context.PresencePenalty, + MaxOutputTokens = context.MaxTokens, + }; + } + + private static List BuildPrompts(OrchestrationContext context) + { + var prompts = new List(); + var systemMessage = context.CompletionContext.SystemMessage; + + if (!string.IsNullOrWhiteSpace(systemMessage)) + { + prompts.Add(new ChatMessage(ChatRole.System, systemMessage)); + } + + var history = context.ConversationHistory? + .Where(message => message.Role == ChatRole.User || message.Role == ChatRole.Assistant) + .Where(message => !string.IsNullOrWhiteSpace(message.Text)) + .ToList() ?? []; + + if (context.CompletionContext.PastMessagesCount > 1) + { + var count = Math.Min(history.Count, context.CompletionContext.PastMessagesCount.Value); + prompts.AddRange(history.Skip(Math.Max(0, history.Count - count))); + } + else + { + prompts.AddRange(history); + } + + if (prompts.Count == 0 || prompts[^1].Text != context.UserMessage) + { + prompts.Add(new ChatMessage(ChatRole.User, context.UserMessage)); + } + + return prompts; + } + + private static ChatResponseUpdate CreateTextResponse(string text) + { + return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(text)], + }; + } +} diff --git a/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs index 58734181..b127f8b9 100644 --- a/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI.Copilot/ServiceCollectionExtensions.cs @@ -18,7 +18,7 @@ public static IServiceCollection AddCoreAICopilotOrchestrator(this IServiceColle services.AddHttpClient(); services.AddOrchestrator(CopilotOrchestrator.OrchestratorName) - .WithTitle("GitHub Copilot Orchestrator"); + .WithTitle("Copilot"); services.TryAddScoped(); diff --git a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs index 4b919306..c0d7f4e3 100644 --- a/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs +++ b/src/Primitives/CrestApps.Core.AI/ServiceCollectionExtensions.cs @@ -398,7 +398,7 @@ public static IServiceCollection AddCoreAIOrchestration(this IServiceCollection services.TryAddEnumerable(ServiceDescriptor.Scoped()); services.AddOrchestrator(DefaultOrchestrator.OrchestratorName) - .WithTitle("Default Orchestrator"); + .WithTitle("Default"); services.AddScoped(); diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs index d4e15f58..9cd40d0f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AIProfileController.cs @@ -1,5 +1,7 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.A2A.Models; +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Claude.Services; using CrestApps.Core.AI.Copilot.Models; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.DataSources; @@ -40,11 +42,13 @@ public sealed class AIProfileController : Controller private readonly ISearchIndexProfileStore _indexProfileStore; private readonly ITemplateService _aiTemplateService; private readonly OrchestratorOptions _orchestratorOptions; + private readonly ClaudeOptions _anthropicOptions; + private readonly ClaudeClientService _anthropicClientService; private readonly CopilotOptions _copilotOptions; private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; private readonly IAIDataSourceStore _dataSourceStore; - public AIProfileController(IAIProfileManager profileManager, ICatalog deploymentCatalog, IAIProfileTemplateManager templateManager, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, IAIDocumentStore documentStore, AIProfileDocumentService profileDocumentService, AIProfileTemplateDocumentService templateDocumentService, IOptions interactionDocumentOptions, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, IOptions copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions, IAIDataSourceStore dataSourceStore) + public AIProfileController(IAIProfileManager profileManager, ICatalog deploymentCatalog, IAIProfileTemplateManager templateManager, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, IAIDocumentStore documentStore, AIProfileDocumentService profileDocumentService, AIProfileTemplateDocumentService templateDocumentService, IOptions interactionDocumentOptions, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, IOptions anthropicOptions, ClaudeClientService anthropicClientService, IOptions copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions, IAIDataSourceStore dataSourceStore) { _profileManager = profileManager; _deploymentCatalog = deploymentCatalog; @@ -58,6 +62,8 @@ public AIProfileController(IAIProfileManager profileManager, ICatalog d.Type.Supports(AIDeploymentType.Utility) || d.Type.Supports(AIDeploymentType.Chat)).Select(d => new SelectListItem(BuildDeploymentLabel(d), d.Name)).ToList(); var orchestrators = _orchestratorOptions.GetOrchestratorDescriptors(); model.Orchestrators = orchestrators.Select(o => new SelectListItem(o.Value.Title ?? o.Key, o.Key)).ToList(); + model.ClaudeIsConfigured = _anthropicOptions.IsConfigured(); + await PopulateClaudeModelsAsync(model); // Copilot model.CopilotAuthenticationType = (int)_copilotOptions.AuthenticationType; model.CopilotIsConfigured = IsCopilotConfigured(); @@ -424,6 +432,15 @@ private static void ApplyTemplateToProfile(AIProfile profile, AIProfileTemplate profile.Remove(); } + if (template.TryGet(out var anthropicMetadata)) + { + profile.Put(new ClaudeSessionMetadata { ClaudeModel = anthropicMetadata.ClaudeModel, }); + } + else + { + profile.Remove(); + } + if (!string.IsNullOrWhiteSpace(metadata.Description)) { profile.Description = metadata.Description; @@ -452,5 +469,17 @@ private static string BuildDeploymentLabel(AIDeployment deployment) return string.Equals(deployment.Name, deployment.ModelName, StringComparison.OrdinalIgnoreCase) ? deployment.Name : $"{deployment.Name} ({deployment.ModelName})"; } + private async Task PopulateClaudeModelsAsync(AIProfileViewModel model) + { + if (!_anthropicOptions.IsConfigured()) + { + model.AnthropicAvailableModels = ClaudeModelSelectListFactory.Build([], model.ClaudeModel, _anthropicOptions.DefaultModel); + return; + } + + var models = await _anthropicClientService.ListModelsAsync(); + model.AnthropicAvailableModels = ClaudeModelSelectListFactory.Build(models, model.ClaudeModel, _anthropicOptions.DefaultModel); + } + private bool IsCopilotConfigured() => _copilotOptions.IsConfigured(); -} \ No newline at end of file +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs index c8c7354f..57ede790 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Controllers/AITemplateController.cs @@ -1,5 +1,7 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.A2A.Models; +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Claude.Services; using CrestApps.Core.AI.Copilot.Models; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.DataSources; @@ -40,10 +42,12 @@ public sealed class AITemplateController : Controller private readonly ISearchIndexProfileStore _indexProfileStore; private readonly ITemplateService _aiTemplateService; private readonly OrchestratorOptions _orchestratorOptions; + private readonly ClaudeOptions _anthropicOptions; + private readonly ClaudeClientService _anthropicClientService; private readonly CopilotOptions _copilotOptions; private readonly GitHubOAuthService _oauthService; private readonly AIToolDefinitionOptions _toolOptions; - public AITemplateController(ICatalog catalog, ICatalog deploymentCatalog, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, IAIDataSourceStore dataSourceStore, IAIProfileManager profileManager, IAIDocumentStore documentStore, AIProfileTemplateDocumentService templateDocumentService, IOptions interactionDocumentOptions, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, IOptions copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions) + public AITemplateController(ICatalog catalog, ICatalog deploymentCatalog, ICatalog a2aConnectionCatalog, ICatalog mcpConnectionCatalog, IAIDataSourceStore dataSourceStore, IAIProfileManager profileManager, IAIDocumentStore documentStore, AIProfileTemplateDocumentService templateDocumentService, IOptions interactionDocumentOptions, ISearchIndexProfileStore indexProfileStore, ITemplateService aiTemplateService, IOptions orchestratorOptions, IOptions anthropicOptions, ClaudeClientService anthropicClientService, IOptions copilotOptions, GitHubOAuthService oauthService, IOptions toolOptions) { _catalog = catalog; _deploymentCatalog = deploymentCatalog; @@ -57,6 +61,8 @@ public AITemplateController(ICatalog catalog, ICatalog d.Type.Supports(AIDeploymentType.Utility) || d.Type.Supports(AIDeploymentType.Chat)).Select(d => new SelectListItem(BuildDeploymentLabel(d), d.Name)).ToList(); var orchestrators = _orchestratorOptions.GetOrchestratorDescriptors(); model.Orchestrators = orchestrators.Select(o => new SelectListItem(o.Value.Title ?? o.Key, o.Key)).ToList(); + model.ClaudeIsConfigured = _anthropicOptions.IsConfigured(); + await PopulateClaudeModelsAsync(model); model.CopilotAuthenticationType = _copilotOptions.AuthenticationType; model.CopilotIsConfigured = IsCopilotConfigured(); await PopulateCopilotStatusAsync(model); @@ -290,6 +298,18 @@ private static string BuildDeploymentLabel(AIDeployment deployment) return string.Equals(deployment.Name, deployment.ModelName, StringComparison.OrdinalIgnoreCase) ? deployment.Name : $"{deployment.Name} ({deployment.ModelName})"; } + private async Task PopulateClaudeModelsAsync(AITemplateViewModel model) + { + if (!_anthropicOptions.IsConfigured()) + { + model.AnthropicAvailableModels = ClaudeModelSelectListFactory.Build([], model.ClaudeModel, _anthropicOptions.DefaultModel); + return; + } + + var models = await _anthropicClientService.ListModelsAsync(); + model.AnthropicAvailableModels = ClaudeModelSelectListFactory.Build(models, model.ClaudeModel, _anthropicOptions.DefaultModel); + } + private async Task PopulateCopilotStatusAsync(AITemplateViewModel model) { if (_copilotOptions.AuthenticationType != CopilotAuthenticationType.GitHubOAuth) @@ -317,4 +337,4 @@ private async Task PopulateCopilotStatusAsync(AITemplateViewModel model) } private bool IsCopilotConfigured() => _copilotOptions.IsConfigured(); -} \ No newline at end of file +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs index 4d65160f..a09eed66 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AIProfileViewModel.cs @@ -1,5 +1,7 @@ using System.Text.Json; using CrestApps.Core.AI.A2A.Models; +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Claude.Services; using CrestApps.Core.AI.Copilot.Models; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.Mcp.Models; @@ -132,6 +134,11 @@ public sealed class AIProfileViewModel // Memory public bool EnableUserMemory { get; set; } + // Anthropic + public string ClaudeModel { get; set; } + + public bool ClaudeIsConfigured { get; set; } + // Copilot public string CopilotModel { get; set; } @@ -166,6 +173,9 @@ public sealed class AIProfileViewModel [BindNever] public IEnumerable CopilotAvailableModels { get; set; } = []; + [BindNever] + public IEnumerable AnthropicAvailableModels { get; set; } = []; + public static AIProfileViewModel FromProfile(AIProfile profile) { var settings = profile.GetSettings(); @@ -329,6 +339,11 @@ public static AIProfileViewModel FromProfile(AIProfile profile) vm.CopilotIsAllowAll = copilotMeta.IsAllowAll; } + if (profile.TryGet(out var anthropicMeta)) + { + vm.ClaudeModel = anthropicMeta.ClaudeModel; + } + return vm; } @@ -527,6 +542,19 @@ public void ApplyTo(AIProfile profile) m.EnableUserMemory = EnableUserMemory; }); + if (!string.IsNullOrEmpty(OrchestratorName) && + string.Equals(OrchestratorName, ClaudeOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) + { + profile.Alter(metadata => + { + metadata.ClaudeModel = ClaudeModel; + }); + } + else + { + profile.Remove(); + } + // Copilot metadata if (!string.IsNullOrEmpty(OrchestratorName) && string.Equals(OrchestratorName, CopilotOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs index dd1c0a8a..b8550e33 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/ViewModels/AITemplateViewModel.cs @@ -1,5 +1,7 @@ using System.Text.Json; using CrestApps.Core.AI; +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.AI.Claude.Services; using CrestApps.Core.AI.Copilot.Models; using CrestApps.Core.AI.Copilot.Services; using CrestApps.Core.AI.Mcp.Models; @@ -114,6 +116,11 @@ public sealed class AITemplateViewModel // Memory. public bool EnableUserMemory { get; set; } + // Anthropic. + public string ClaudeModel { get; set; } + + public bool ClaudeIsConfigured { get; set; } + // Settings. public bool IsRemovable { get; set; } = true; public bool LockSystemMessage { get; set; } @@ -143,6 +150,9 @@ public sealed class AITemplateViewModel [BindNever] public IEnumerable CopilotAvailableModels { get; set; } = []; + [BindNever] + public IEnumerable AnthropicAvailableModels { get; set; } = []; + [BindNever] public IEnumerable DataSources { get; set; } = []; @@ -303,6 +313,11 @@ public static AITemplateViewModel FromTemplate(AIProfileTemplate template) model.CopilotModel = copilotMetadata.CopilotModel; model.CopilotIsAllowAll = copilotMetadata.IsAllowAll; } + + if (template.TryGet(out var anthropicMetadata)) + { + model.ClaudeModel = anthropicMetadata.ClaudeModel; + } } return model; @@ -500,6 +515,19 @@ public void ApplyTo(AIProfileTemplate template) IsRemovable = IsRemovable, }); + if (!string.IsNullOrEmpty(OrchestratorName) && + string.Equals(OrchestratorName, ClaudeOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) + { + template.Put(new ClaudeSessionMetadata + { + ClaudeModel = ClaudeModel, + }); + } + else + { + template.Remove(); + } + if (!string.IsNullOrEmpty(OrchestratorName) && string.Equals(OrchestratorName, CopilotOrchestrator.OrchestratorName, StringComparison.OrdinalIgnoreCase)) { @@ -516,6 +544,7 @@ public void ApplyTo(AIProfileTemplate template) } else { + template.Remove(); template.Remove(); } } diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml index db519691..7cbcff3f 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Create.cshtml @@ -163,6 +163,12 @@ { "CopilotIsAllowAll", Model.CopilotIsAllowAll }, { "CopilotModels", Model.CopilotAvailableModels }, }) + @await Html.PartialAsync("_ClaudeConfig", null, new ViewDataDictionary(ViewData) { + { "ClaudePrefix", "ap" }, + { "ClaudeIsConfigured", Model.ClaudeIsConfigured }, + { "ClaudeModel", Model.ClaudeModel }, + { "ClaudeModels", Model.AnthropicAvailableModels }, + })
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml index d110504b..502a9d15 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AIProfile/Edit.cshtml @@ -218,6 +218,12 @@ { "CopilotIsAllowAll", Model.CopilotIsAllowAll }, { "CopilotModels", Model.CopilotAvailableModels }, }) + @await Html.PartialAsync("_ClaudeConfig", null, new ViewDataDictionary(ViewData) { + { "ClaudePrefix", "ap" }, + { "ClaudeIsConfigured", Model.ClaudeIsConfigured }, + { "ClaudeModel", Model.ClaudeModel }, + { "ClaudeModels", Model.AnthropicAvailableModels }, + })
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml index 65dff4c1..fed142da 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Create.cshtml @@ -153,6 +153,13 @@ { "CopilotIsAllowAll", Model.CopilotIsAllowAll }, { "CopilotModels", Model.CopilotAvailableModels }, }) + @await Html.PartialAsync("_ClaudeConfig", null, new ViewDataDictionary(ViewData) + { + { "ClaudePrefix", "at" }, + { "ClaudeIsConfigured", Model.ClaudeIsConfigured }, + { "ClaudeModel", Model.ClaudeModel }, + { "ClaudeModels", Model.AnthropicAvailableModels }, + })
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml index b3f734bf..6ab1cd1d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AI/Views/AITemplate/Edit.cshtml @@ -154,6 +154,13 @@ { "CopilotIsAllowAll", Model.CopilotIsAllowAll }, { "CopilotModels", Model.CopilotAvailableModels }, }) + @await Html.PartialAsync("_ClaudeConfig", null, new ViewDataDictionary(ViewData) + { + { "ClaudePrefix", "at" }, + { "ClaudeIsConfigured", Model.ClaudeIsConfigured }, + { "ClaudeModel", Model.ClaudeModel }, + { "ClaudeModels", Model.AnthropicAvailableModels }, + })
diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeAuthenticationType.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeAuthenticationType.cs new file mode 100644 index 00000000..ba14367c --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeAuthenticationType.cs @@ -0,0 +1,7 @@ +namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Models; + +public enum ClaudeAuthenticationType +{ + NotConfigured = 0, + ApiKey = 1, +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeSettings.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeSettings.cs new file mode 100644 index 00000000..1cff92c7 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Models/ClaudeSettings.cs @@ -0,0 +1,12 @@ +namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Models; + +public sealed class ClaudeSettings +{ + public ClaudeAuthenticationType AuthenticationType { get; set; } + + public string BaseUrl { get; set; } = "https://api.anthropic.com"; + + public string ProtectedApiKey { get; set; } + + public string DefaultModel { get; set; } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeModelSelectListFactory.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeModelSelectListFactory.cs new file mode 100644 index 00000000..1313995d --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeModelSelectListFactory.cs @@ -0,0 +1,37 @@ +using CrestApps.Core.AI.Claude.Models; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; + +internal static class ClaudeModelSelectListFactory +{ + public static List Build( + IEnumerable models, + params string[] fallbackModelIds) + { + var items = (models ?? []) + .Where(model => !string.IsNullOrWhiteSpace(model.Id)) + .Select(model => new SelectListItem( + string.IsNullOrWhiteSpace(model.Name) ? model.Id : model.Name, + model.Id)) + .ToList(); + + var knownIds = items + .Select(item => item.Value) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var modelId in fallbackModelIds ?? []) + { + if (string.IsNullOrWhiteSpace(modelId) || !knownIds.Add(modelId)) + { + continue; + } + + items.Add(new SelectListItem(modelId, modelId)); + } + + return items + .OrderBy(item => item.Text, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeSettingsExtensions.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeSettingsExtensions.cs new file mode 100644 index 00000000..ca33b938 --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/ClaudeSettingsExtensions.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.Mvc.Web.Areas.AIChat.Models; + +namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; + +public static class ClaudeSettingsExtensions +{ + public static bool IsConfigured(this ClaudeSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + + return settings.AuthenticationType switch + { + ClaudeAuthenticationType.ApiKey => !string.IsNullOrWhiteSpace(settings.ProtectedApiKey), + _ => false, + }; + } + + public static bool IsConfigured(this ClaudeOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return !string.IsNullOrWhiteSpace(options.ApiKey); + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcClaudeOptionsConfiguration.cs b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcClaudeOptionsConfiguration.cs new file mode 100644 index 00000000..a19e33ad --- /dev/null +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Services/MvcClaudeOptionsConfiguration.cs @@ -0,0 +1,58 @@ +using CrestApps.Core.AI.Claude.Models; +using CrestApps.Core.Mvc.Web.Areas.AIChat.Models; +using CrestApps.Core.Mvc.Web.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; + +namespace CrestApps.Core.Mvc.Web.Areas.AIChat.Services; + +internal sealed class MvcClaudeOptionsConfiguration : IConfigureOptions +{ + private const string ProtectorPurpose = "CrestApps.Core.Mvc.Web.ClaudeSettings"; + + private readonly SiteSettingsStore _siteSettings; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly ILogger _logger; + + public MvcClaudeOptionsConfiguration( + SiteSettingsStore siteSettings, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + _siteSettings = siteSettings; + _dataProtectionProvider = dataProtectionProvider; + _logger = logger; + } + + public void Configure(ClaudeOptions options) + { + var settings = _siteSettings.Get(); + if (settings == null) + { + return; + } + + options.BaseUrl = settings.BaseUrl; + options.DefaultModel = settings.DefaultModel; + + if (settings.AuthenticationType != ClaudeAuthenticationType.ApiKey) + { + return; + } + + if (string.IsNullOrWhiteSpace(settings.ProtectedApiKey)) + { + return; + } + + try + { + var protector = _dataProtectionProvider.CreateProtector(ProtectorPurpose); + options.ApiKey = protector.Unprotect(settings.ProtectedApiKey); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to unprotect Anthropic API key."); + } + } +} diff --git a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml index 6d4bb8df..17e60b7d 100644 --- a/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml +++ b/src/Startup/CrestApps.Core.Mvc.Web/Areas/AIChat/Views/AIChat/Chat.cshtml @@ -45,7 +45,6 @@ } -