Skip to content

Commit 5479a54

Browse files
authored
Add Vision Support (#461)
1 parent f6d3d99 commit 5479a54

76 files changed

Lines changed: 517 additions & 225 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/copilot-instructions.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ dotnet run
104104
* When an object initializer spans multiple lines, place each property assignment on its own line.
105105
* Format conditional (`?:`) operators across multiple lines with the condition, `?`, and `:` each on their own properly indented lines.
106106
* Always keep exactly one trailing newline at the end of each file.
107+
* In `.cshtml` files, never introduce stray carriage returns (`^M`) or CRCRLF line endings; keep view files normalized so they do not render with artificial blank lines.
107108

108109
#### `#pragma` Rules
109110

@@ -159,6 +160,7 @@ dotnet run
159160
#### JavaScript in `.cshtml`
160161

161162
* Ensure proper indentation for all JavaScript inside `.cshtml` files.
163+
* When editing `.cshtml` files, preserve normalized line endings and remove any stray `^M`/CRCRLF formatting damage instead of leaving it in place.
162164

163165
#### Architecture & Design
164166

@@ -397,6 +399,7 @@ If CloudSmith is inaccessible, only asset builds and code analysis are possible.
397399
- **Authorization handlers**: Do not constructor-inject `IAuthorizationService` into an `AuthorizationHandler`. Resolve and cache it lazily inside the handler because the authorization pipeline can otherwise create circular dependencies.
398400
- **Collection handling**: Do not call `.ToList()` or `.ToArray()` unless a concrete snapshot is truly required for correctness or lifetime safety. Prefer consuming `IEnumerable<T>` directly when you only need to iterate.
399401
- **Localization extraction**: When using `ILocalizer`, the property/variable must be named `S`, and localized strings must use the literal pattern `S["This is a localized string"]`. Do not use variables inside the brackets because extraction tooling looks specifically for `S["..."]`.
402+
- **Select lists from enums**: Do not build Orchard `SelectListItem` collections by iterating enums with `Enum.GetValues(...)` and piping `ToString()` into the text. Define each item explicitly so the title uses `S["..."]` for extraction and multi-word labels can use the correct spacing.
400403
- **Settings UI casing**: Use sentence case for settings labels, hints, and warning headings. Keep placement tab/card/column names and admin menu labels in title case.
401404
- **Catalog entry handlers**: When a feature must react to create, update, or delete operations for catalog-backed models, prefer `CatalogEntryHandlerBase<T>` registered as `ICatalogEntryHandler<T>` and route write operations through the matching catalog manager so the handler events actually run. Do not rely on raw store writes alone when handler lifecycle behavior is required.
402405
- **Deferred third-party work**: When a catalog handler must call indexing systems, external APIs, or other third-party/background-style services, follow the deferred task pattern used by `ChatInteractionHandler` and `AIMemoryEntryHandler`: capture the affected models/IDs in the scoped handler and schedule the work with `ShellScope.AddDeferredTask(...)` so the external work runs later instead of inline during the catalog event.

Directory.Packages.props

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
55
<OrchardCoreVersion>[3.0.0-preview-19022, )</OrchardCoreVersion>
6-
<CrestAppsCoreVersion>1.0.0-preview.108</CrestAppsCoreVersion>
6+
<CrestAppsCoreVersion>1.0.0-preview.112</CrestAppsCoreVersion>
77
<ModelContextProtocolVersion>1.3.0</ModelContextProtocolVersion>
88
</PropertyGroup>
99
<ItemGroup>
@@ -131,7 +131,7 @@
131131
<ItemGroup>
132132
<!-- Testing Packages -->
133133
<PackageVersion Include="Moq" Version="4.20.72" />
134-
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
134+
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
135135
<PackageVersion Include="xunit.v3" Version="3.2.2" />
136136
<PackageVersion Include="xunit.analyzers" Version="1.27.0" />
137137
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
@@ -159,4 +159,4 @@
159159
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="$(ModelContextProtocolVersion)" />
160160
<PackageVersion Include="ModelContextProtocol.Core" Version="$(ModelContextProtocolVersion)" />
161161
</ItemGroup>
162-
</Project>
162+
</Project>

src/Core/CrestApps.OrchardCore.AI.Chat.Core/Hubs/AIChatHub.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,8 @@ protected override async Task ProcessGeneratedPromptAsync(
333333
}, cancellationToken);
334334

335335
var deploymentManager = services.GetRequiredService<IAIDeploymentManager>();
336-
var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.Chat, deploymentName: completionContext.ChatDeploymentName, cancellationToken: cancellationToken)
337-
?? throw new InvalidOperationException("Unable to resolve a chat deployment for the profile.");
336+
var chatDeployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentPurpose.Chat, deploymentName: completionContext.ChatDeploymentName, cancellationToken: cancellationToken)
337+
?? throw new InvalidOperationException("Unable to resolve a chat deployment for the profile.");
338338

339339
using var builder = ZString.CreateStringBuilder();
340340
var references = new Dictionary<string, AICompletionReference>();

src/Core/CrestApps.OrchardCore.AI.Core/Handlers/AIDeploymentHandler.cs

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,9 @@ public override async Task ValidatingAsync(ValidatingContext<AIDeployment> conte
7070
context.Result.Fail(new ValidationResult(S["Model name is required."], [nameof(AIDeployment.ModelName)]));
7171
}
7272

73-
if (!context.Model.Type.IsValidSelection())
73+
if (!context.Model.Purpose.IsValidSelection())
7474
{
75-
context.Result.Fail(new ValidationResult(S["The deployment type '{0}' is not valid.", context.Model.Type], [nameof(AIDeployment.Type)]));
75+
context.Result.Fail(new ValidationResult(S["The deployment purpose '{0}' is not valid.", context.Model.Purpose], [nameof(AIDeployment.Purpose)]));
7676
}
7777

7878
var requiresConnection = !HasContainedConnection(context.Model.ClientName);
@@ -160,9 +160,10 @@ private static Task PopulateAsync(AIDeployment deployment, JsonNode data)
160160

161161
PopulateContainedConnectionAliases(deployment, data);
162162

163-
if (TryGetDeploymentType(data[nameof(AIDeployment.Type)], out var type))
163+
if (TryGetDeploymentPurpose(data[nameof(AIDeployment.Purpose)], out var purpose) ||
164+
TryGetDeploymentPurpose(data["Type"], out purpose))
164165
{
165-
deployment.Type = type;
166+
deployment.Purpose = purpose;
166167
}
167168

168169
var properties = data[nameof(AIDeployment.Properties)]?.AsObject();
@@ -200,38 +201,38 @@ private static void CopyStringProperty(JsonNode data, AIDeployment deployment, s
200201
deployment.Properties[propertyName] = propertyValue;
201202
}
202203

203-
private static bool TryGetDeploymentType(JsonNode typeNode, out AIDeploymentType type)
204+
private static bool TryGetDeploymentPurpose(JsonNode purposeNode, out AIDeploymentPurpose purpose)
204205
{
205-
type = AIDeploymentType.None;
206+
purpose = AIDeploymentPurpose.None;
206207

207-
if (typeNode is null)
208+
if (purposeNode is null)
208209
{
209210
return false;
210211
}
211212

212-
if (typeNode is JsonArray array)
213+
if (purposeNode is JsonArray array)
213214
{
214215
foreach (var item in array)
215216
{
216217
if (item is null ||
217-
!Enum.TryParse<AIDeploymentType>(item.GetValue<string>(), ignoreCase: true, out var parsedType) ||
218-
parsedType == AIDeploymentType.None)
218+
!Enum.TryParse<AIDeploymentPurpose>(item.GetValue<string>(), ignoreCase: true, out var parsedPurpose) ||
219+
parsedPurpose == AIDeploymentPurpose.None)
219220
{
220-
type = AIDeploymentType.None;
221+
purpose = AIDeploymentPurpose.None;
221222
return false;
222223
}
223224

224-
type |= parsedType;
225+
purpose |= parsedPurpose;
225226
}
226227

227-
return type.IsValidSelection();
228+
return purpose.IsValidSelection();
228229
}
229230

230-
var typeValue = typeNode.GetValue<string>();
231+
var purposeValue = purposeNode.GetValue<string>();
231232

232-
return !string.IsNullOrEmpty(typeValue) &&
233-
Enum.TryParse(typeValue, ignoreCase: true, out type) &&
234-
type.IsValidSelection();
233+
return !string.IsNullOrEmpty(purposeValue) &&
234+
Enum.TryParse(purposeValue, ignoreCase: true, out purpose) &&
235+
purpose.IsValidSelection();
235236
}
236237

237238
private bool HasContainedConnection(string clientName)

src/Core/CrestApps.OrchardCore.AI.Core/Services/DataSourceIndexingService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -871,7 +871,7 @@ private async Task<AIDeployment> ResolveEmbeddingDeploymentAsync(
871871

872872
var configuredDeployment = await _deploymentManager.FindByNameAsync(embeddingDeploymentName, cancellationToken);
873873

874-
if (configuredDeployment?.SupportsType(AIDeploymentType.Embedding) == true)
874+
if (configuredDeployment?.SupportsPurpose(AIDeploymentPurpose.Embedding) == true)
875875
{
876876
return configuredDeployment;
877877
}
@@ -908,7 +908,7 @@ private async Task<AIDeployment> ResolveCanonicalEmbeddingDeploymentAsync(
908908
return null;
909909
}
910910

911-
var deployments = (await _deploymentManager.GetByTypeAsync(AIDeploymentType.Embedding, cancellationToken) ?? [])
911+
var deployments = (await _deploymentManager.GetByPurposeAsync(AIDeploymentPurpose.Embedding, cancellationToken) ?? [])
912912
.Where(deployment => MatchesEmbeddingSelector(deployment, selectorCandidates))
913913
.ToArray();
914914

@@ -986,7 +986,7 @@ private static bool MatchesEmbeddingSelector(AIDeployment deployment, HashSet<st
986986
ArgumentNullException.ThrowIfNull(deployment);
987987
ArgumentNullException.ThrowIfNull(selectorCandidates);
988988

989-
return deployment.SupportsType(AIDeploymentType.Embedding) &&
989+
return deployment.SupportsPurpose(AIDeploymentPurpose.Embedding) &&
990990
(selectorCandidates.Contains(deployment.Name) ||
991991
selectorCandidates.Contains(deployment.ModelName));
992992
}

src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultSpeechVoicePresenter.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public async Task<IEnumerable<SelectListItem>> GetVoiceMenuItemsAsync(string dep
5050
{
5151
var deployment = !string.IsNullOrEmpty(deploymentName)
5252
? await _deploymentManager.FindByNameAsync(deploymentName)
53-
: await _deploymentManager.ResolveOrDefaultAsync(AIDeploymentType.TextToSpeech);
53+
: await _deploymentManager.ResolveOrDefaultAsync(AIDeploymentPurpose.TextToSpeech);
5454

5555
if (deployment == null)
5656
{

src/Core/CrestApps.OrchardCore.Recipes.Core/Schemas/AIDeploymentRecipeStep.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ private static JsonSchema CreateSchema()
5050
("ApiKey", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("Contained-connection API key alias for recipe imports. Supported by AzureSpeech deployments.")),
5151
("IdentityId", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("Contained-connection managed identity client ID alias for recipe imports. Supported by AzureSpeech deployments.")),
5252
("Properties", containedConnectionPropertiesSchema),
53-
("Type", new JsonSchemaBuilder().AnyOf(
54-
new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The deployment type, or a comma-separated flag value such as 'Chat, Utility'. Defaults to Chat when not specified."),
53+
("Purpose", new JsonSchemaBuilder().AnyOf(
54+
new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The deployment purpose, or a comma-separated flag value such as 'Chat, Utility'. Defaults to Chat when not specified."),
5555
new JsonSchemaBuilder().Type(SchemaValueType.Array).Items(
56-
new JsonSchemaBuilder().Type(SchemaValueType.String).Enum("Chat", "Utility", "Embedding", "Image", "SpeechToText", "TextToSpeech")).MinItems(1).UniqueItems(true).Description("The deployment types."))))
56+
new JsonSchemaBuilder().Type(SchemaValueType.String).Enum("Chat", "Utility", "Embedding", "Image", "SpeechToText", "TextToSpeech", "Vision")).MinItems(1).UniqueItems(true).Description("The deployment purposes."))))
5757
.Required("Name")
5858
.AdditionalProperties(true);
5959

src/Core/CrestApps.OrchardCore.Recipes.Core/Schemas/SiteSettings/DefaultAIDeploymentSettingsSchema.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ protected override JsonSchemaBuilder BuildSchemaCore()
2424
("DefaultUtilityDeploymentName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The technical name of the default utility deployment.")),
2525
("DefaultEmbeddingDeploymentName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The technical name of the default embedding deployment.")),
2626
("DefaultImageDeploymentName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The technical name of the default image generation deployment.")),
27+
("DefaultVisionDeploymentName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The technical name of the default vision deployment.")),
2728
("DefaultSpeechToTextDeploymentName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The technical name of the default speech-to-text deployment.")),
2829
("DefaultTextToSpeechDeploymentName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The technical name of the default text-to-speech deployment.")),
2930
("DefaultTextToSpeechVoiceId", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The voice identifier to use for text-to-speech.")))

src/CrestApps.Docs/docs/ai/chat-interactions.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ This module provides ad-hoc AI chat interactions with configurable parameters, e
3131
- User memory — persist private, non-sensitive preferences and durable background details for authenticated users
3232
- Chat mode — configurable voice interaction modes (Text Only, Audio Input, Conversation) for speech-to-text dictation and two-way voice chat
3333
- Prompt-template composition — add multiple reusable prompt templates from a searchable picker and provide per-template JSON parameters
34+
- Vision-aware knowledge uploads — the Knowledge tab updates its supported formats immediately when you switch the chat deployment, so image-capable models expose image upload extensions without reloading the page
3435

3536
## Getting Started
3637

@@ -47,6 +48,8 @@ When a response cites uploaded or indexed content, the interaction UI renders `[
4748

4849
The admin **Chat Interactions** list includes integrated search, multi-select, and bulk actions through the shared list management resource used across CrestApps admin catalogs.
4950

51+
When the **AI Documents** feature is enabled, the **Knowledge** tab shows the current supported upload formats directly under the file picker. That list refreshes immediately when you change the selected chat deployment, including the **Default** option when the tenant default chat deployment supports vision.
52+
5053
## Orchestration
5154

5255
Each chat interaction session is bound to an orchestrator that manages the execution pipeline. The orchestrator handles:
@@ -185,4 +188,3 @@ To enable image generation, create an `AIDeployment` record with type `Image` fo
185188
}
186189
}
187190
```
188-

src/CrestApps.Docs/docs/ai/chat.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ If a profile allows session documents, the chat UI keeps restored widget session
142142

143143
The admin and frontend chat widgets restore their saved toggle and panel positions before the chat app finishes initializing.
144144

145+
The admin session chat now shows the same compact **Supported formats** note above the input attachment bar that the widgets already expose, so users can see the exact upload extensions allowed for the current profile and vision capability.
146+
145147
By default, session-document uploads are stored on the local file system through the shared AI Documents storage pipeline. If you want widget uploads stored in Azure Blob Storage instead, enable `CrestApps.OrchardCore.AI.Documents.Azure` and configure it as described in [AI Documents - Azure Blob Storage](./documents/azure-blob-storage.md).
146148

147149
### Citations and references

0 commit comments

Comments
 (0)