The New Era of Smart Editors: Developer Express and AI Integration
The new era of smart editors is already here. Developer Express has introduced AI functionality in many of their controls for .NET (Windows Forms, Blazor, WPF, MAUI).
This advancement will eventually come to XAF, but in the meantime, here at XARI, we are experimenting with XAF integrations to add value to our customers.
In this article, we are going to integrate the new chat component into an XAF application, and our first use case will be RAG (Retrieval-Augmented Generation). RAG is a system that combines external data sources with AI-generated responses, improving accuracy and relevance in answers by retrieving information from a document set or knowledge base and using it in conjunction with AI predictions.
To achieve this integration, we will follow the steps outlined in this tutorial:
When I implement my own property editor, I usually avoid doing so for primitive types because, in most cases, my property editor will need more information than a simple primitive value. For this implementation, I want to handle a custom value in my property editor. I typically create an interface to represent the type, ensuring compatibility with both XPO and EF Core.
After defining the type for my editor, I need to create a non-persistent implementation:
namespace XafSmartEditors.Razor.RagChat
{
[DomainComponent]
public class IRagDataImp : IRagData, IXafEntityObject, INotifyPropertyChanged
{
private void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public IRagDataImp()
{
Oid = Guid.NewGuid();
}
[DevExpress.ExpressApp.Data.Key]
[Browsable(false)]
public Guid Oid { get; set; }
private string prompt;
private string fileName;
private Stream fileContent;
public Stream FileContent
{
get => fileContent;
set
{
if (fileContent == value) return;
fileContent = value;
OnPropertyChanged();
}
}
public string FileName
{
get => fileName;
set
{
if (fileName == value) return;
fileName = value;
OnPropertyChanged();
}
}
public string Prompt
{
get => prompt;
set
{
if (prompt == value) return;
prompt = value;
OnPropertyChanged();
}
}
// IXafEntityObject members
void IXafEntityObject.OnCreated() { }
void IXafEntityObject.OnLoaded() { }
void IXafEntityObject.OnSaving() { }
public event PropertyChangedEventHandler PropertyChanged;
}
}
Creating the Blazor Chat Component
Now, it’s time to create our Blazor component and add the new DevExpress chat component for Blazor:
<DxAIChat CssClass="my-chat" Initialized="Initialized"
RenderMode="AnswerRenderMode.Markdown"
UseStreaming="true"
SizeMode="SizeMode.Medium">
<EmptyMessageAreaTemplate>
<div class="my-chat-ui-description">
<span style="font-weight: bold; color: #008000;">Rag Chat</span> Assistant is ready to answer your questions.
</div>
</EmptyMessageAreaTemplate>
<MessageContentTemplate>
<div class="my-chat-content">
@ToHtml(context.Content)
</div>
</MessageContentTemplate>
</DxAIChat>
@code {
IRagData _value;
[Parameter]
public IRagData Value
{
get => _value;
set => _value = value;
}
async Task Initialized(IAIChat chat)
{
await chat.UseAssistantAsync(new OpenAIAssistantOptions(
this.Value.FileName,
this.Value.FileContent,
this.Value.Prompt
));
}
MarkupString ToHtml(string text)
{
return (MarkupString)Markdown.ToHtml(text);
}
}
The main takeaway from this component is that it receives a parameter named Value of type IRagData, and we use this value to initialize the IAIChat service in the Initialized method.
Creating the Component Model
With the interface and domain component in place, we can now create the component model to communicate the value of our domain object with the Blazor component:
namespace XafSmartEditors.Razor.RagChat
{
public class RagDataComponentModel : ComponentModelBase
{
public IRagData Value
{
get => GetPropertyValue<IRagData>();
set => SetPropertyValue(value);
}
public EventCallback<IRagData> ValueChanged
{
get => GetPropertyValue<EventCallback<IRagData>>();
set => SetPropertyValue(value);
}
public override Type ComponentType => typeof(RagChat);
}
}
Creating the Property Editor
Finally, let’s create the property editor class that serves as a bridge between XAF and the new component:
That’s everything we need to create a RAG system using XAF and the new DevExpress Chat component. You can find the complete source code here: GitHub Repository.
If you want to meet and discuss AI, XAF, and .NET, feel free to schedule a meeting: Schedule a Meeting.
The eXpressApp Framework (XAF) from DevExpress is a versatile application framework that supports multiple UI platforms, including Windows Forms and Blazor. Maintaining separate property editors for each platform can be cumbersome. This article explores how to create unified property editors for both Windows Forms and Blazor by leveraging WebView for Windows Forms and the Monaco Editor, the editor used in Visual Studio Code.
Blazor Implementation
Windows forms Implementation
Prerequisites
Before we begin, ensure you have the following installed:
Visual Studio 2022 or later
.NET 8.0 SDK or later
DevExpress XAF 22.2 or later
Step 1: Create a XAF Application for Windows Forms and Blazor
Create a New Solution:
Open Visual Studio and create a new solution.
Add two projects to this solution:
A Windows Forms project.
A Blazor project.
Set Up XAF:
Follow the DevExpress documentation to set up XAF in both projects. Official documentation here
Step 2: Create a Razor Class Library
Create a Razor Class Library:
Add a new Razor Class Library project to the solution.
Define the data model. This is important; the idea is to use objects and not “by val” types like strings.
Create the tag helper and the service extension, source here. This step is optional and is only to make integration easier. A full article about that is available here.
We are done with the shared library that we will reference in both Blazor and Windows projects.
Step 3: Integrate the Razor Class Library into Windows Forms
Add NuGet References:
In the Windows Forms project, add the following NuGet packages:
XafVsCodeEditor (the Razor Class Library created earlier).
You can see all the references in the csproj file.
Change the Project Type: In order to add the ability to host Blazor components, we need to change the project SDK from Microsoft.NET.Sdk to Microsoft.NET.Sdk.Razor.
Add Required Files:
wwwroot: folder to host CSS, JavaScript, and the index.html.
_Imports.razor: this file adds global imports. Source here.
index.html: one of the most important files because it hosts a special blazor.webview.js to interact with the WebView. See here.
I’m not going to show the full steps to create the property editors. Instead, I will focus on the most important parts of the editor. Let’s start with Windows.
In Windows Forms, the most important method is when you create the instance of the control, in this case, the WebView. As you can see, this is where you instantiate the services that will be passed as a parameter to the component, in our case, the data model. You can find the full implementation of the property editor for Windows here and the official DevExpress documentation here.
protected override object CreateControlCore()
{
control = new BlazorWebView();
control.Dock = DockStyle.Fill;
var services = new ServiceCollection();
services.AddWindowsFormsBlazorWebView();
control.HostPage = "wwwroot\\index.html";
var tags = MonacoEditorTagHelper.AddScriptTags;
control.Services = services.BuildServiceProvider();
parameters = new Dictionary<string, object>();
if (PropertyValue == null)
{
PropertyValue = new MonacoEditorData() { Language = "markdown" };
}
parameters.Add("Value", PropertyValue);
control.RootComponents.Add<MonacoEditorComponent>("#app", parameters);
control.Size = new System.Drawing.Size(300, 300);
return control;
}
Now, for the property editor for Blazor, you can find the full source code here and the official DevExpress documentation here.
protected override IComponentModel CreateComponentModel()
{
var model = new MonacoEditorDataModel();
model.ValueChanged = EventCallback.Factory.Create<IMonacoEditorData>(this, value => {
model.Value = value;
OnControlValueChanged();
WriteValue();
});
return model;
}
One of the most important things to notice here is that in version 24 of XAF, Blazor property editors have been simplified so they require fewer layers of code. The magical databinding happens because in the data model there should be a property of the same value and type as one of the parameters in the Blazor component.
Step 5: Running the Application
Before we run our solution, we need to add a domain object that implements a property of type IMonacoData, which is the interface we associated with our property editor. Here is a sample domain object that has a property of type MonacoEditorData:
[DefaultClassOptions]
public class DomainObject1 : BaseObject, IXafEntityObject
{
public DomainObject1(Session session) : base(session) { }
public override void AfterConstruction()
{
base.AfterConstruction();
}
MonacoEditorData monacoEditorData;
string text;
public MonacoEditorData MonacoEditorData
{
get => monacoEditorData;
set => SetPropertyValue(nameof(MonacoEditorData), ref monacoEditorData, value);
}
[Size(SizeAttribute.DefaultStringMappingFieldSize)]
public string Text
{
get => text;
set => SetPropertyValue(nameof(Text), ref text, value);
}
public void OnCreated()
{
this.MonacoEditorData = new MonacoEditorData("markdown", "");
MonacoEditorData.PropertyChanged += SourceEditor_PropertyChanged;
}
void IXafEntityObject.OnSaving()
{
this.Text = this.MonacoEditorData.Code;
}
void IXafEntityObject.OnLoaded()
{
this.MonacoEditorData = new MonacoEditorData("markdown", this.Text);
MonacoEditorData.PropertyChanged += SourceEditor_PropertyChanged;
}
private void SourceEditor_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
this.Text = this.MonacoEditorData.Code;
}
}
As you can see, DomainObject1 implements the interface IXafEntityObject. We are using the interface events to load and save the content of the editor to the text property.
Now, build and run the solution. You should now have a Windows Forms application that hosts a Blazor property editor using WebView and the Monaco Editor, as well as a Blazor application using the same property editor.
By leveraging WebView and the Monaco Editor, you can create unified property editors for both Windows Forms and Blazor in XAF applications. This approach simplifies maintenance and provides a consistent user experience across different platforms. With the flexibility of Blazor and the robustness of Windows Forms, you can build powerful and versatile property editors that cater to a wide range of user needs.
I have been using XPO from DevExpress since day one. For me is the best O.R.M in the dot net world, so when I got the news that XPO was going to be free of charge I was really happy because that means I can use it in every project without adding cost for my customers.
Nowadays all my customer needs some type of mobile development, so I have decided to master the combination of XPO and Xamarin
Now there is a problem when using XPO and Xamarin and that is the network topology, database connections are no designed for WAN networks.
Let’s take MS SQL server as an example, here are the supported communication protocols
TCP/IP.
Named Pipes
To quote what Microsoft web site said about using the protocols above in a WAN network
In a fast-local area network (LAN) environment, Transmission Control Protocol/Internet Protocol (TCP/IP) Sockets and Named Pipes clients are comparable with regard to performance. However, the performance difference between the TCP/IP Sockets and Named Pipes clients becomes apparent with slower networks, such as across wide area networks (WANs) or dial-up networks. This is because of the different ways the interprocess communication (IPC) mechanisms communicate between peers.”
So, what other options do we have? Well if you are using the full DotNet framework you can use WCF.
So, it looks like WCF is the solution here since is mature and robust communication framework but there is a problem, the implementation of WCF for mono touch (Xamarin iOS) and mono droid (Xamarin Android)
You can read about Xamarin limitations in the following links
I don’t want to go into details about how the limitation of each platform affects XPO and WCF but basically the main limitation is the ability to use reflection and emit new code which is needed to generate the WCF client, also in WCF there are problems in the serialization behaviors.
So basically, what we need to do is to replace the WCF layer with some other technology to communicate to the database server
The technology I’ve selected for this AspNetCore which I would say is a really nice technology that is modern, multi-platform and easy to use. Here below you can see what is the architecture of the solution
AspNetCore
Rest API
So, what we need basically is to be able to communicate the data layer with the data store through a network architecture.
The network architecture that I have chosen is a rest API which is one of the strong fronts of AspNetCore. The rest API will work as the server that forward the communication from XPO to the Database and vice versa, you can find a project template of the server implementation here https://www.jocheojeda.com/download/560/ this implementation references one nuget where I have written the communication code, you can fine the nuget here https://nuget.bitframeworks.com/feeds/main/BIT.Xpo.AgnosticDataStore.Server/19.1.5.1
Sometimes we need to have clientside events and handle them on the server side code behind, that in a simple asp.net web page is really easy, you just have to execute a javascript that executes an HTTP request to the server. Now the question is, how do we do that in XAF?
Well, the concept is basically the same but you need to know XAF architecture the problem is that most of the code needed is not documented, but after a while, I manage to figure it out, so let’s get started.
1) Create a XAF web application
2) On your web module add a view controller
3) Implement the interface IXafCallbackHandler on the controller you just added in step 2, this is the method that will be called as a callback from javascript. This interface is not documented on the DevExpress website
4) In your view controller add a property to access XafCallbackManager
5) Override the OnViewControlsCreated method and register your callback, in this example, the name of the callback is “MyScript”
6) Now add a simple action and wire the execute event, on the execute event cast the frame as a web window and register a startup script. The code surrounded with the blue line is the javascript that triggers the callback in the callback manager, the code surrounded with red is the id if the script that we are listening for, it should match the name of the script registered on the handler in the previous step.
To execute the callback somewhere in your javascript you have to execute the following function RaiseXafCallback, this function is not documented on the DevExpress website
7) Run your application and execute the simple action added in step 6, when the javascript finish executing, the method you implemented on step 3 will be executed.
The code for this article is here the full example is in GitHub
Sometimes you are asked by a customer to implement a functionality that you have already created in the past from someone else.
In this case, a customer and dear friend from Italy asked me “Jose, can we make the XAF filter case insensitive?”
And my answer was yes, I have done in the past for a customer in El Salvador. At that time the approach we used was to save everything in uppercases and do all the searches in uppercases also
I didn’t like that approach at all, so it was time get creative again.
As always I start my research by spending some time reading tickets at https://www.devexpress.com/Support/Center/ which is the greatest source of information for all things related to DevExpress
My first idea was to edit the criteria that were sent to the collection source that means that I need a way to get the values of each node on the criteria, so I can edit them one by one. After some more research, this is what I have found
this approach seems perfect to get the properties and values of each node till test it and find out this
Michael (DevExpress Support)6 years ago
It is possible to write a hierarchy walker or visitor, but only for a specific subset of criteria operators.
What Michael means is that only you can only get the values of simple criteria and by simple, I mean the ones that do not involve functions. So, I was set back to step one again.
So, I’m back to step one but let’s see what I’ve learned from the other tickets
I know the exact moment when the criteria are evaluated
I know the criteria that are evaluated and how many nodes it contains
I know which the possible properties are involved in the creation of the criteria
Now this is the flow that I will use to make the search case insensitive
Locate the controller where the search action lives, for that, I can use the property frame and the method get controller
Interrupt the search action, for that, I will wire the event execute of the FilterController FullTextFilterAction action
Find out what are the possible properties that I can use to build the search criteria for that I will use GetFullTextSearchProperties
Build the new criteria, this is somehow tricky because the properties shown in the list view might represent complex objects and that is shown in the column is either the object default property or any other property that can be deep inside the properties tree. Also this step involve find out what is the type of the value being displayed on the list view, if the value is a string I will evaluate its value converted to upper cases using the function upper from the criteria language, if the search value is not a string but a number I will try to cast it to the type of the display column, if the cast is not invalid I will evaluate the search value as a number
Set the new filter to the list view
You can cross reference the points above with the sample code below
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using DevExpress.Data.Filtering;
using DevExpress.ExpressApp;
using DevExpress.ExpressApp.Actions;
using DevExpress.ExpressApp.Editors;
using DevExpress.ExpressApp.Layout;
using DevExpress.ExpressApp.Model;
using DevExpress.ExpressApp.Model.NodeGenerators;
using DevExpress.ExpressApp.SystemModule;
using DevExpress.ExpressApp.Templates;
using DevExpress.ExpressApp.Utils;
using DevExpress.Persistent.Base;
using DevExpress.Persistent.Validation;
public class FilterControllerCaseInsensitive : ViewController<ListView>
{
public FilterControllerCaseInsensitive()
{
}
FilterController standardFilterController;
protected override void OnActivated()
{
standardFilterController = Frame.GetController<FilterController>();
if (standardFilterController == null)
return;
//we should wire the execution of the filter
standardFilterController.FullTextFilterAction.Execute += FullTextFilterAction_Execute;
}
protected override void OnDeactivated()
{
base.OnDeactivated();
if (standardFilterController == null)
return;
//we should unwire the execution of the filter
standardFilterController.FullTextFilterAction.Execute -= FullTextFilterAction_Execute;
}
private void FullTextFilterAction_Execute(object sender, ParametrizedActionExecuteEventArgs e)
{
//we locate filter with the key FilterController.FullTextSearchCriteriaName then we convert it to case insensitive
if (!string.IsNullOrEmpty(e.ParameterCurrentValue as string) && View.CollectionSource.Criteria.ContainsKey(FilterController.FullTextSearchCriteriaName))
View.CollectionSource.Criteria[FilterController.FullTextSearchCriteriaName] = GetCaseInsensitiveCriteria(e.ParameterCurrentValue, View.CollectionSource.Criteria[FilterController.FullTextSearchCriteriaName]);
}
private CriteriaOperator GetCaseInsensitiveCriteria(object searchValue, CriteriaOperator initialCriteria)
{
//we get a list of all the properties that can be involved in the filter
var SearchProperties = standardFilterController.GetFullTextSearchProperties();
//we declare a model class and a property name,the values on this variables will change if we property involve is a navigation property (another persistent object)
IModelClass ModelClass = null;
string PropertyName = string.Empty;
//we declare a list of operators to contains new operators we are going to create
List<CriteriaOperator> Operator = new List<CriteriaOperator>();
//we iterate all the properties
foreach (var CurrentProperty in SearchProperties)
{
//here we split the name with a dot, if length is greater than 1 it means its a navigation properties, beware that this may fail with a deep tree of properties like category.subcategory.categoryname
var Split = CurrentProperty.Split('.');
if (Split.Length > 1)
{
Debug.WriteLine(string.Format("{0}","its a complex property"));
var CurrentClass = this.View.Model.ModelClass;
for (int i = 0; i < Split.Length; i++)
{
//if its a navigation property we locate the type in the BOModel
IModelMember member = CurrentClass.OwnMembers.Where(m => m.Name == Split[i]).FirstOrDefault();
//then we set the model class and property name to the values of the navigation property like category.name where category is the model class and name is the property
CurrentClass = this.Application.Model.BOModel.GetClass(member.Type);
if (CurrentClass == null)
continue;
ModelClass = CurrentClass;
PropertyName = Split[i + 1];
}
Debug.WriteLine(string.Format("{0}:{1}", "ModelClass", ModelClass.Name));
Debug.WriteLine(string.Format("{0}:{1}", "PropertyName", PropertyName));
}
else
{
//else the model class will be the current class where the filter is executing, and the property will be the current property we are evaluating
ModelClass = this.View.Model.ModelClass;
PropertyName = CurrentProperty;
}
//we look for the property on the class model own member
var Property = ModelClass.OwnMembers.Where(m => m.Name == PropertyName).FirstOrDefault();
if (Property != null)
{
//if the property is a string it means that we can set it to upper case
if (Property.Type == typeof(string))
{
searchValue = searchValue.ToString().ToUpper();
//we create an operator where we set the value of the property to upper before we compare it, also we change the comparison value to upper
CriteriaOperator Operand = CriteriaOperator.Parse("Contains(Upper(" + CurrentProperty + "), ?)", searchValue);
//we added to the list of operators that will concatenate with OR
Operator.Add(Operand);
}
else
{
//if the property is not a string we need to try to cast the value to the correct type so we do a catch try, if we manage to cast the value it will be added to the operators list
try
{
var ConvertedType = Convert.ChangeType(searchValue, Property.Type);
CriteriaOperator operand = new BinaryOperator(CurrentProperty, ConvertedType, BinaryOperatorType.Equal);
Operator.Add(operand);
}
catch (Exception)
{
//silent exception, this will happen if the casting was not successful so we won't add the operand on this case
}
}
}
}
//we concatenate everything with an OR
var alloperators = CriteriaOperator.Or(Operator.ToArray());
Debug.WriteLine(string.Format("{0}:{1}", "alloperators", alloperators));
return alloperators;
}
}