專案簡介:目標是開發一個跨平臺的AI聊天和其他功能的使用者端平臺。目的來學習和了解Avalonia。將這個專案部署在openKylin 1.0 的系統上。
為什麼使用Avalonia:之前已經瞭解了基於Avalonia的專案在國產麒麟系統中執行的案例。正是Avalonia在跨平臺的出色表現,學習和了解Avalonia這個UI框架顯得十分有必要。本專案採用的是最新穩定版本11.0.0-rc1.1。希望通過該專案瞭解和學習Avalonia開發的朋友可以在我的github上拉取程式碼,同時希望大家多多點點star。
https://github.com/raokun/TerraMours.Chat.Ava
專案的基礎框架和通用功能在上一篇部落格中介紹過了,想了解的同學跳轉學習:
基於Avalonia 11.0.0+ReactiveUI 的跨平臺專案開發1-通用框架
瞭解Avalonia建立模板專案-基礎可跳轉:
本次我主要分享的內容是專案中具體功能開發實現的過程和各技術的應用
第一版的內容主要分為以下幾個模組:
下面我會按照各個模組來介紹對應的功能和實現方法。
載入介面 是系統的首個載入介面,介面樣式如下:
載入介面是系統在執行前的準備介面,目前並沒有做什麼操作,只是做了個進度條,到100%時跳轉首頁。不過這是一個可延伸的實踐。
載入介面完成了首頁的切換的實踐,為後期登入頁面做好了準備。同時,載入介面的內容,改寫成蒙版,在需要長時間資料處理用於限制使用者操作也是不錯的選擇。
設定首個載入介面,需要在App.axaml.cs中的OnFrameworkInitializationCompleted方法中設定 desktop.MainWindow
OnFrameworkInitializationCompleted程式碼如下:
public override void OnFrameworkInitializationCompleted() {
//新增共用資源
var VMLocator = new VMLocator();
Resources.Add("VMLocator", VMLocator);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) {
var load= new LoadView {
DataContext = new LoadViewModel(),
};
desktop.MainWindow = load;
VMLocator.LoadViewModel.ToMainAction = () =>
{
desktop.MainWindow = new MainWindow();
desktop.MainWindow.Show();
load.Close();
};
}
base.OnFrameworkInitializationCompleted();
}
載入介面不應該有關閉等按鈕,我們用 SystemDecorations="None"。將 SystemDecorations
屬性設定為 "None"
可以隱藏視窗的系統裝飾。系統裝飾包括標題列、最小化、最大化和關閉按鈕等。通過設定 SystemDecorations
為 "None"
,可以使視窗更加客製化化和個性化,同時減少了不必要的系統裝飾。
介面應該顯示在螢幕正中間。我們用 WindowStartupLocation="CenterScreen"。設定 WindowStartupLocation
為 "CenterScreen"
可以使視窗在螢幕上居中顯示。
程式碼如下:
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="10">
<TextBlock
Text="Loading..."
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="White"
Background="Transparent"/>
<ProgressBar
x:Name="progressBar"
HorizontalAlignment="Center"
Minimum="0"
Maximum="100"
Value="{Binding Progress}"
Width="200"
Height="20"
Background="Transparent">
<ProgressBar.Foreground>
<SolidColorBrush Color="White"/>
</ProgressBar.Foreground>
</ProgressBar>
</StackPanel>
進度條用到了ProgressBar 的控制元件,對應的官方檔案地址:ProgressBar
控制元件的屬性:
Property | Description |
---|---|
Minimum |
最大值 |
Maximum |
最小值 |
Value |
當前值 |
Foreground |
進度條顏色 |
ShowProgressText |
顯示進度數值 |
Value 值通過Binding繫結了ViewModel中的屬性欄位Progress。通過UpdateProgress()方法,讓Progress的值由0變化到100,模擬載入過程。
程式碼如下:
private async void UpdateProgress() {
// 模擬登入載入過程
for (int i = 0; i <= 100; i++) {
Progress = i;
await Task.Delay(100); // 延遲一段時間,以模擬載入過程
}
ToMainAction?.Invoke();
}
介面的跳轉,通過Action委託來完成,首先在LoadViewModel中定義 ToMainAction,在上面的UpdateProgress方法完成時執行Invoke,而ToMainAction的實現方法,寫在OnFrameworkInitializationCompleted方法中。
ToMainAction的實現方法中,將desktop.MainWindow變更成MainWindow。loadView隱藏,MainWindow顯示。
載入介面 是承載程式的介面,介面樣式如下:
首頁 主要作用是承載程式的介面,每一個Avalonia專案在建立時會自動建立MainWindow.axaml 在介面axaml中很簡單。承載了MainView 的使用者控制元件,和API設定介面。
首頁 包括控制API設定的資料互動、鍵盤的監聽事件、系統語言的判斷。
API設定 包括用於OpenAI介面呼叫引數的全部設定。
程式碼如下:
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:TerraMours.Chat.Ava.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="TerraMours.Chat.Ava.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
xmlns:dialogHost="clr-namespace:DialogHostAvalonia;assembly=DialogHost.Avalonia"
RenderOptions.BitmapInterpolationMode="HighQuality"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:local="using:TerraMours.Chat.Ava.Views"
Icon="/Assets/terramours.ico"
Title="TerraMours.Chat.Ava">
<dialogHost:DialogHost IsOpen="{Binding ApiSettingIsOpened}"
DialogMargin="16"
DisableOpeningAnimation="True"
dialogHost:DialogHostStyle.CornerRadius="8"
Background="rgb(52, 53, 65)">
<dialogHost:DialogHost.DialogContent>
<local:ApiSettingsView />
</dialogHost:DialogHost.DialogContent>
<Panel>
<local:MainView />
</Panel>
</dialogHost:DialogHost>
</Window>
介面中使用到了 DialogHost.Avalonia,做彈框的簡單實現。IsOpen 控制彈窗的顯隱性。DialogHost.DialogContent 中填入彈框的顯示內容。顯示內容為
ApiSettingsView。
而主體部分只有一個Panel,包含著MainView,這使得MainView的介面會佔滿整個程式介面。至此,首頁的介面設計就完成了。
Icon="/Assets/terramours.ico" 設定了程式的logo,如下:
MainWindow 在介面載入時要做很多工作。MainWindow的建構函式如下:
程式碼如下:
public MainWindow() {
InitializeComponent();
this.Closing += (sender, e) => SaveWindowSizeAndPosition();
this.Loaded += MainWindow_Loaded;
MainWindowViewModel = new MainWindowViewModel();
VMLocator.MainWindowViewModel = MainWindowViewModel;
DataContext = MainWindowViewModel;
var cultureInfo = CultureInfo.CurrentCulture;
if (cultureInfo.Name == "zh-CN") {
Translate("zh-CN");
}
this.KeyDown += MainWindow_KeyDown;
}
MainWindow的建構函式繫結了多個事件的實現方法:
在MainWindow建構函式中,通過判斷CultureInfo.CurrentCulture,獲取當前 作業系統的語言系統,判斷程式應該顯示哪個國家的語言。從而確定程式顯示的語言,通過Translate 修改語言相關設定。是系統國際化的實踐。
系統設定 對應AppSettings類,記錄了應用程式設定和 ChatGPT API引數
系統設定引數通過儲存到檔案settings.json中實現設定的在地化和持久化。
程式碼如下:
private async void MainWindow_Loaded(object sender,RoutedEventArgs e) {
var settings = await LoadAppSettingsAsync();
if (File.Exists(Path.Combine(settings.AppDataPath, "settings.json"))) {
this.Width = settings.Width - 1;
this.Position = new PixelPoint(settings.X, settings.Y);
this.Height = settings.Height;
this.Width = settings.Width;
this.WindowState = settings.IsMaximized ? WindowState.Maximized : WindowState.Normal;
}
else {
var screen = Screens.Primary;
var workingArea = screen.WorkingArea;
double dpiScaling = screen.PixelDensity;
this.Width = 1300 * dpiScaling;
this.Height = 840 * dpiScaling;
this.Position = new PixelPoint(5, 0);
}
if (!File.Exists(settings.DbPath)) {
_dbProcess.CreateDatabase();
}
await _dbProcess.DbLoadToMemoryAsync();
await VMLocator.MainViewModel.LoadPhraseItemsAsync();
VMLocator.MainViewModel.SelectedPhraseItem = settings.PhrasePreset;
VMLocator.MainViewModel.SelectedLogPain = "Chat List";
await Dispatcher.UIThread.InvokeAsync(() => { VMLocator.MainViewModel.LogPainIsOpened = false; });
if (this.Width > 1295) {
//await Task.Delay(1000);
await Dispatcher.UIThread.InvokeAsync(() => { VMLocator.MainViewModel.LogPainIsOpened = true; });
}
this.GetObservable(ClientSizeProperty).Subscribe(size => OnSizeChanged(size));
_previousWidth = ClientSize.Width;
await _dbProcess.UpdateChatLogDatabaseAsync();
await _dbProcess.CleanUpEditorLogDatabaseAsync();
if (string.IsNullOrWhiteSpace(VMLocator.MainWindowViewModel.ApiKey)) {
var dialog = new ContentDialog() { Title = $"Please enter your API key.", PrimaryButtonText = "OK" };
await VMLocator.MainViewModel.ContentDialogShowAsync(dialog);
VMLocator.ChatViewModel.OpenApiSettings();
}
}
MainWindow_Loaded 的方法中,通過解析settings.json,載入系統設定。
相關方法如下:
至此,系統設定的開發就基本完成了。對於這些不需要遠端同步的基礎設定,儲存在本地檔案中。
通過Translate方法,根據當前系統語言,改變控制文字顯示的資原始檔,實現語言的切換。
程式碼如下:
public void Translate(string targetLanguage) {
var translations = App.Current.Resources.MergedDictionaries.OfType<ResourceInclude>().FirstOrDefault(x => x.Source?.OriginalString?.Contains("/Lang/") ?? false);
if (translations != null)
App.Current.Resources.MergedDictionaries.Remove(translations);
App.Current.Resources.MergedDictionaries.Add(
(ResourceDictionary)AvaloniaXamlLoader.Load(
new Uri($"avares://TerraMours.Chat.Ava/Assets/lang/{targetLanguage}.axaml")
)
);
}
關於國際化的資原始檔的建立請看前篇內容:基於Avalonia 11.0.0+ReactiveUI 的跨平臺專案開發1-通用框架
主介面 是專案的核心,包括了以下圖片所有內容的佈局,它勾勒出了整個程式。中間包括左上角的圖示,對談列表,聊天區域,查詢,設定等等。
主介面 的作用,是顯示和完成整個業務功能的展示和互動。主介面 將對談列表和聊天視窗左右分開。控制了整個程式的排版和佈局。
主要分三塊:
具體程式碼不貼出來了,需要了解的同學可以fork專案程式碼檢視,功能區已經標註註釋了,方便檢視。
對談列表和聊天視窗 通過SplitView 實現,對談列表在視窗縮小時自動隱藏。通過IsPaneOpen屬性控制。
隱藏效果:
實現方法為OnSizeChanged方法:
程式碼如下:
private void OnSizeChanged(Size newSize) {
if (_previousWidth != newSize.Width) {
if (newSize.Width <= 1295) {
VMLocator.MainViewModel.LogPainIsOpened = false;
VMLocator.MainViewModel.LogPainButtonIsVisible = false;
}
else {
if (VMLocator.MainViewModel.LogPainButtonIsVisible == false) {
VMLocator.MainViewModel.LogPainButtonIsVisible = true;
}
if (newSize.Width > _previousWidth) {
VMLocator.MainViewModel.LogPainIsOpened = true;
}
}
_previousWidth = newSize.Width;
}
}
當視窗寬度小於1295,會修改VMLocator.MainViewModel.LogPainButtonIsVisible為false,實現對談列表隱藏的效果。
MainViewModel控制了程式大部分的按鍵的事件實現,MainViewModel的建構函式如下:
程式碼如下:
public MainViewModel() {
PostButtonText = "Post";
LoadChatListCommand = ReactiveCommand.CreateFromTask<string>(async (keyword) => await LoadChatListAsync(keyword));
PhrasePresetsItems = new ObservableCollection<string>();
//對談
ImportChatLogCommand = ReactiveCommand.CreateFromTask(ImportChatLogAsync);
ExportChatLogCommand = ReactiveCommand.CreateFromTask(ExportChatLogAsync);
DeleteChatLogCommand = ReactiveCommand.CreateFromTask(DeleteChatLogAsync);
//設定
SystemMessageCommand = ReactiveCommand.Create(InsertSystemMessage);
HotKeyDisplayCommand = ReactiveCommand.CreateFromTask(HotKeyDisplayAsync);
OpenApiSettingsCommand = ReactiveCommand.Create(OpenApiSettings);
ShowDatabaseSettingsCommand = ReactiveCommand.CreateFromTask(ShowDatabaseSettingsAsync);
//聊天
PostCommand = ReactiveCommand.CreateFromTask(PostChatAsync);
}
其中,繫結了對談、設定、聊天等功能的按鈕事件。實現業務的互動。
通過Betalgo.OpenAI 完成介面呼叫,是一個開源的nuget包,整合了OpenAI的介面,簡化了呼叫邏輯。
本來更傾向於Senmantic Kernel的,是微軟開發的LLM訓練框架,但是代理方面我還沒有很好的解決辦法,後面再替換。
介面呼叫方法寫在PostChatAsync方法裡,通過post按鈕發起呼叫:
程式碼如下:
/// <summary>
/// OpenAI 呼叫方法
/// </summary>
/// <returns></returns>
private async Task PostChatAsync()
{
try
{
string message = PostMessage;
int conversationId = 1;
//建立對談
if(VMLocator.DataGridViewModel.ChatList == null)
{
VMLocator.DataGridViewModel.ChatList=new ObservableCollection<ChatList> ();
VMLocator.DataGridViewModel.ChatList.Add(new ChatList() { Id=1,Title=(message.Length< 5?message:$"{message.Substring(0,5)}..."), Category = (message.Length < 5 ? message : $"{message.Substring(0, 5)}...") ,Date=DateTime.Now});
}
if (VMLocator.ChatViewModel.ChatHistory == null)
VMLocator.ChatViewModel.ChatHistory = new ObservableCollection<Models.ChatMessage>();
VMLocator.ChatViewModel.ChatHistory.Add(new Models.ChatMessage() { ChatRecordId = 1, ConversationId = conversationId, Message = message, Role = "User", CreateDate = DateTime.Now });
//根據設定中的CONTEXT_COUNT 查詢上下文
var messages = new List<OpenAI.ObjectModels.RequestModels.ChatMessage>();
messages.Add(OpenAI.ObjectModels.RequestModels.ChatMessage.FromUser(message));
var openAiOpetions = new OpenAI.OpenAiOptions()
{
ApiKey = AppSettings.Instance.ApiKey,
BaseDomain = AppSettings.Instance.ApiUrl
};
var openAiService = new OpenAIService(openAiOpetions);
//呼叫SDK
var response = await openAiService.ChatCompletion.CreateCompletion(new ChatCompletionCreateRequest
{
Messages = messages,
Model = AppSettings.Instance.ApiModel,
MaxTokens = AppSettings.Instance.ApiMaxTokens,
});
if (response == null)
{
var dialog = new ContentDialog()
{
Title = "介面呼叫失敗",
PrimaryButtonText = "Ok"
};
await VMLocator.MainViewModel.ContentDialogShowAsync(dialog);
}
if (!response.Successful)
{
var dialog = new ContentDialog()
{
Title = $"介面呼叫失敗,報錯內容: {response.Error.Message}",
PrimaryButtonText = "Ok"
};
await VMLocator.MainViewModel.ContentDialogShowAsync(dialog);
}
VMLocator.ChatViewModel.ChatHistory.Add(new Models.ChatMessage() { ChatRecordId = 2, ConversationId = conversationId, Message = response.Choices.FirstOrDefault().Message.Content, Role = "Assistant", CreateDate = DateTime.Now });
VMLocator.MainViewModel.PostMessage = "";
}
catch (Exception e)
{
}
}
通過建立OpenAIService初始化,Completion介面呼叫時使用openAiService.ChatCompletion.CreateCompletion方法。
ChatMessage是上下文的模型,通過建立messages完成上下文的建立,請求引數都寫在ChatCompletionCreateRequest之中。
目前的第一版使用的CreateCompletion是直接返回的結果。後面我會優化呼叫,使用Stream流式輸出。
對談列表是模擬chatgpt官網的樣式,將聊天按對談的形式歸類。chatgpt官網截圖如下:
對談列表將聊天按對談的形式歸類,更好的管理聊天內容。
因為考慮到後面會有其他型別的AI 型別,決定通過DataGrid實現對談列表,DataGrid的表格型別也能更多的展示資料。
程式碼如下:
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
xmlns:vm="using:TerraMours.Chat.Ava.ViewModels"
x:DataType="vm:DataGridViewModel"
xmlns:local="using:TerraMours.Chat.Ava"
x:Class="TerraMours.Chat.Ava.Views.DataGridView">
<UserControl.Resources>
<local:CustomDateConverter x:Key="CustomDateConverter" />
</UserControl.Resources>
<Grid>
<DataGrid Name="ChatListDataGrid"
ItemsSource="{Binding ChatList}"
AutoGenerateColumns="False"
HeadersVisibility="None"
SelectionMode="Single"
SelectedItem="{Binding SelectedItem}"
SelectedIndex="{Binding SelectedItemIndex}">
<DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Id}" IsVisible="False"/>
<DataGridTextColumn Foreground="rgb(155,155,155)"
FontSize="12"
Binding="{Binding Date,Converter={StaticResource CustomDateConverter},Mode=OneWay}"
IsReadOnly="True"/>
<DataGridTextColumn Binding="{Binding Title}" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</UserControl>
對談列表的資料共有三個:Id,建立時間,對談標題。通過DataGridTextColumn 通過繫結的形式實現。
其中,使用了CustomDateConverter 時間轉換器,將Date的顯示格式做轉換。
對談列表目前還在優化,第一版是通過第一次呼叫PostChatAsync時建立的。目前的資料存在本地SQLite資料庫中。
聊天視窗是程式的工作重心,是展示聊天成果的重要介面。其中用到Markdown.Avalonia的擴充套件包實現Markdown內容的展示。
資料是核心,聊天視窗是資料的展示平臺,作用不容小噓。通過編寫資料模板DataTemplate來控制內容的展示。呈現chat問答式的結果。
通過DataTemplate來設定Markdown 風格樣式。程式碼如下:
<DataTemplate>
<Border
Name="MessageBorder"
Background="{Binding Role, Converter={StaticResource ChatBackgroundConverter}}"
HorizontalAlignment="Left"
Padding="5"
Margin="20,5,20,20"
CornerRadius="8,8,8,0">
<md:MarkdownScrollViewer
VerticalAlignment="Stretch"
MarkdownStyleName="Standard"
SaveScrollValueWhenContentUpdated="True"
TextElement.FontSize="16"
TextElement.Foreground="White"
Markdown="{Binding Message}">
<md:MarkdownScrollViewer.Styles>
<Style Selector="ctxt|CCode">
<Style.Setters>
<Setter Property="BorderBrush" Value="Green"/>
<Setter Property="BorderThickness" Value="2"/>
<Setter Property="Padding" Value="2"/>
<Setter Property="MonospaceFontFamily" Value="Meiryo" />
<Setter Property="Foreground" Value="DarkGreen" />
<Setter Property="Background" Value="LightGreen" />
</Style.Setters>
</Style>
<Style Selector="Border.CodeBlock">
<Style.Setters>
<Setter Property="BorderBrush" Value="#E2E6EA" />
<Setter Property="BorderThickness" Value="0,30,0,0" />
<Setter Property="Margin" Value="5,0,5,0" />
<Setter Property="Background" Value="Black" />
</Style.Setters>
</Style>
<Style Selector="TextBlock.CodeBlock">
<Style.Setters>
<Setter Property="Background" Value="Black" />
</Style.Setters>
</Style>
<Style Selector="avedit|TextEditor">
<Style.Setters>
<Setter Property="BorderBrush" Value="#E2E6EA" />
<Setter Property="Background" Value="Black" />
<Setter Property="Padding" Value="5"></Setter>
</Style.Setters>
</Style>
</md:MarkdownScrollViewer.Styles>
<md:MarkdownScrollViewer.ContextMenu>
<ContextMenu Padding="3">
<MenuItem>
<MenuItem.Header>
<TextBlock>編輯</TextBlock>
</MenuItem.Header>
</MenuItem>
<!--<MenuItem Tag="{Binding ChatRecordId}" Click="DeleteClick">
<MenuItem.Header>
<TextBlock>刪除</TextBlock>
</MenuItem.Header>
</MenuItem>
<MenuItem Tag="{Binding Message}" Click="CopyClick">
<MenuItem.Header>
<TextBlock>複製</TextBlock>
</MenuItem.Header>
</MenuItem>-->
</ContextMenu>
</md:MarkdownScrollViewer.ContextMenu>
</md:MarkdownScrollViewer>
</Border>
</DataTemplate>
MarkdownScrollViewer.Styles 根據不同的內容設定不同的樣式。
MarkdownScrollViewer.ContextMenu設定右鍵選單。
其中通過ChatBackgroundConverter轉換器根據角色控制背景,ChatBackgroundConverter程式碼如下:
avalonia開發目前網上,特別是國內的網站的教學和文章很少,希望能給大家一點學習使用avalonia開發使用者端專案的朋友一點幫助。寫的不對的地方也懇請大家多多留言,我會及時更正,多多交流心得體會。
Todo:
**目前程式還沒有完全開發完成。後續的開發我會及時跟進。閱讀如遇樣式問題,請前往個人部落格瀏覽:https://www.raokun.top
目前web端ChatGPT:https://ai.terramours.site
當前開源專案地址:https://github.com/raokun/TerraMours.Chat.Ava