跨平臺`ChatGpt` 客戶端

tokengo發表於2023-03-05

跨平臺ChatGpt 客戶端

一款基於Avalonia實現的跨平臺ChatGpt客戶端 ,透過對接ChatGpt官方提供的ChatGpt 3.5模型實現聊天對話

實現建立ChatGpt的專案名稱 ,專案型別是Avalonia MVVM

新增專案需要使用的Nuget


    <ItemGroup>
        <PackageReference Include="Avalonia" Version="11.0.0-preview5" />
        <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview5" />
        <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview5" />
        <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
        <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-preview5" />
        <PackageReference Include="FreeSql.Provider.Sqlite" Version="3.2.690" />
        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
        <PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
        <PackageReference Include="XamlNameReferenceGenerator" Version="1.6.1" />
        <PackageReference Include="Avalonia.Svg.Skia" Version="11.0.0-preview5" />
    </ItemGroup>

ViewLocator.cs程式碼修改

using System;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using ChatGPT.ViewModels;

namespace ChatGPT;

public class ViewLocator : IDataTemplate
{
    public Control? Build(object? data)
    {
        if (data is null)
            return null;

        var name = data.GetType().FullName!.Replace("ViewModel", "View");
        var type = Type.GetType(name);

        if (type != null)
        {
            return (Control)Activator.CreateInstance(type)!;
        }

        return new TextBlock { Text = name };
    }

    public bool Match(object? data)
    {
        return data is ViewModelBase;
    }
}

建立MainApp.cs檔案

using Microsoft.Extensions.DependencyInjection;

namespace ChatGPT;

public static class MainApp
{
    private static IServiceProvider ServiceProvider;

    public static ServiceCollection CreateServiceCollection()
    {
        return new ServiceCollection();
    }

    public static IServiceProvider Build(this IServiceCollection services)
    {
        return ServiceProvider = services.BuildServiceProvider();
    }
    
    public static T GetService<T>()
    {
        if (ServiceProvider is null)
        {
            throw new ArgumentNullException(nameof(ServiceProvider));
        }
        return ServiceProvider.GetService<T>();
    }
    
    public static IEnumerable<T> GetServices<T>()
    {
        if (ServiceProvider is null)
        {
            throw new ArgumentNullException(nameof(ServiceProvider));
        }
        return ServiceProvider.GetServices<T>();
    }

    public static object? GetService(Type type)
    {
        if (ServiceProvider is null)
        {
            throw new ArgumentNullException(nameof(ServiceProvider));
        }
        return ServiceProvider.GetService(type);
    }
}

建立GlobalUsing.cs檔案 全域性引用

global using System.Reactive;
global using Avalonia;
global using Avalonia.Controls;
global using ChatGPT.ViewModels;
global using Avalonia;
global using Avalonia.Controls.ApplicationLifetimes;
global using Avalonia.Markup.Xaml;
global using ChatGPT.ViewModels;
global using ChatGPT.Views;
global using System;
global using System.Collections.Generic;
global using ReactiveUI;

修改App.axaml程式碼檔案

<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="using:ChatGPT"
             xmlns:converter="clr-namespace:ChatGPT.Converter"
             RequestedThemeVariant="Light"
             x:Class="ChatGPT.App">
    <Application.Resources>
        <converter:HeightConverter x:Key="HeightConverter" />
    </Application.Resources>
    <Application.DataTemplates>
        <local:ViewLocator/>
    </Application.DataTemplates>

    <Application.Styles>
        <FluentTheme DensityStyle="Compact"/>
    </Application.Styles>
    
</Application>

修改App.axaml.cs程式碼檔案

using Avalonia.Platform;
using Avalonia.Svg.Skia;
using ChatGPT.Options;
using Microsoft.Extensions.DependencyInjection;

namespace ChatGPT;

public partial class App : Application
{
    public override void Initialize()
    {
        GC.KeepAlive(typeof(SvgImageExtension).Assembly);
        GC.KeepAlive(typeof(Avalonia.Svg.Skia.Svg).Assembly);

        var services = MainApp.CreateServiceCollection();

        services.AddHttpClient("chatGpt")
            .ConfigureHttpClient(options =>
            {
                var chatGptOptions = MainApp.GetService<ChatGptOptions>();
                if (!string.IsNullOrWhiteSpace(chatGptOptions?.Token))
                {
                    options.DefaultRequestHeaders.Add("Authorization",
                        "Bearer " + chatGptOptions?.Token.TrimStart().TrimEnd());
                }
            });

        services.AddSingleton<ChatGptOptions>(ChatGptOptions.NewChatGptOptions());

        services.AddSingleton(new FreeSql.FreeSqlBuilder()
            .UseConnectionString(FreeSql.DataType.Sqlite,
                "Data Source=chatGpt.db;Pooling=true;Min Pool Size=1")
            .UseAutoSyncStructure(true) //自動同步實體結構到資料庫
            .Build());

        services.Build();

        AvaloniaXamlLoader.Load(this);
    }

    public override void OnFrameworkInitializationCompleted()
    {
        if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
        {
            desktop.MainWindow = new MainWindow
            {
                DataContext = new MainViewModel()
            };
        }

        var notifyIcon = new TrayIcon();
        notifyIcon.Menu ??= new NativeMenu();
        notifyIcon.ToolTipText = "ChatGPT";

        var assets = AvaloniaLocator.Current.GetService<IAssetLoader>();

        notifyIcon.Icon = new WindowIcon(assets.Open(new Uri("avares://ChatGPT/Assets/chatgpt.ico")));
        var exit = new NativeMenuItem()
        {
            Header = "退出ChatGPT"
        };

        exit.Click += (sender, args) => Environment.Exit(0);
        notifyIcon.Menu.Add(exit);

        base.OnFrameworkInitializationCompleted();
    }
}

修改MainWindow.axaml檔案

<Window 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"
        xmlns:viewModels="clr-namespace:ChatGPT.ViewModels"
        xmlns:pages="clr-namespace:ChatGPT.Pages"
        mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
        x:Class="ChatGPT.Views.MainWindow"
        ExtendClientAreaToDecorationsHint="True"
        ExtendClientAreaChromeHints="NoChrome"
        ExtendClientAreaTitleBarHeightHint="-1"
        Height="{Binding Height}"
        MinHeight="500"
        MinWidth="800"
        Width="1060"
        Name="Main">

    <Design.DataContext>
        <viewModels:MainViewModel />
    </Design.DataContext>

    <StackPanel Name="StackPanel" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
        <WrapPanel Name="WrapPanel" VerticalAlignment="Stretch" Height="{Binding ElementName=StackPanel, Path=Height}">
            <StackPanel MaxWidth="55" Width="55">
                <DockPanel Background="#2E2E2E" Height="{Binding Height}">
                    <StackPanel DockPanel.Dock="Top">
                        <StackPanel Margin="0,32,0,0"></StackPanel>
                        <StackPanel Margin="8">
                            <Image Source="/Assets/avatar.png"></Image>
                        </StackPanel>
                        <StackPanel Name="ChatStackPanel" Margin="15">
                            <Image Source="/Assets/chat-1.png"></Image>
                        </StackPanel>
                    </StackPanel>

                    <StackPanel Margin="5" VerticalAlignment="Bottom" DockPanel.Dock="Bottom">
                        <StackPanel VerticalAlignment="Bottom" Name="FunctionStackPanel">
                            <Menu HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
                                <MenuItem HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
                                    <MenuItem.Header>
                                        <Image Margin="5" Height="20" Width="20" Source="/Assets/function.png"></Image>
                                    </MenuItem.Header>
                                    <MenuItem Click="Setting_OnClick" Name="Setting" Header="設定" />
                                </MenuItem>
                            </Menu>
                        </StackPanel>
                    </StackPanel>
                </DockPanel>
            </StackPanel>

            <Border Width="250" MaxWidth="250" BorderBrush="#D3D3D3" BorderThickness="0,0,1,0">
                <StackPanel>
                    <pages:ChatShowView Name="ChatShowView"/>
                </StackPanel>
            </Border>

            <StackPanel>
                <StackPanel Height="{Binding Height}" HorizontalAlignment="Center" VerticalAlignment="Center">
                    <pages:SendChat DataContext="{Binding SendChatViewModel}"></pages:SendChat>
                </StackPanel>
            </StackPanel>
        </WrapPanel>
    </StackPanel>
</Window>

修改MainWindow.axaml.cs檔案

using Avalonia.Interactivity;
using ChatGPT.Pages;

namespace ChatGPT.Views;

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();

        var observer = Observer.Create<Rect>(rect =>
        {
            if (ViewModel is null) return;

            ViewModel.SendChatViewModel.Height = (int)rect.Height;
            ViewModel.SendChatViewModel.Width = (int)rect.Width - 305;
            ViewModel.Height = (int)rect.Height;
            ViewModel.SendChatViewModel.ShowChatPanelHeight =
                (int)rect.Height - ViewModel.SendChatViewModel.SendPanelHeight - 60;
        });

        this.GetObservable(BoundsProperty).Subscribe(observer);

        ChatShowView = this.Find<ChatShowView>(nameof(ChatShowView));

        ChatShowView.OnClick += view =>
        {
            ViewModel.SendChatViewModel.ChatShow = view;
        };
    }

    private MainViewModel ViewModel => DataContext as MainViewModel;

    private void Setting_OnClick(object? sender, RoutedEventArgs e)
    {
        var setting = new Setting
        {
            DataContext = ViewModel.SettingViewModel
        };
        setting.Show();
    }
}

提供部分程式碼 所有原始碼都是開源,連結防止最下面

效果圖

SendChat.axaml.cs中提供了請求ChatGpt 3.5的實現

using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using Avalonia.Controls.Notifications;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using ChatGPT.Model;
using Notification = Avalonia.Controls.Notifications.Notification;

namespace ChatGPT.Pages;

public partial class SendChat : UserControl
{
    private readonly HttpClient http;

    private WindowNotificationManager? _manager;

    public SendChat()
    {
        http = MainApp.GetService<IHttpClientFactory>().CreateClient("chatGpt");
        InitializeComponent();
        DataContextChanged += async (sender, args) =>
        {
            if (DataContext is not SendChatViewModel model) return;
            if (model.ChatShow != null)
            {
                var freeSql = MainApp.GetService<IFreeSql>();
                try
                {
                    var values = await freeSql.Select<ChatMessage>()
                        .Where(x => x.ChatShowKey == model.ChatShow.Key)
                        .OrderBy(x => x.CreatedTime)
                        .ToListAsync();

                    foreach (var value in values)
                    {
                        model.messages.Add(value);
                    }
                }
                catch (Exception e)
                {
                    Console.WriteLine(e);
                    throw;
                }
            }
            else
            {
                model.ChatShowAction += async () =>
                {
                    var freeSql = MainApp.GetService<IFreeSql>();

                    var values = await freeSql.Select<ChatMessage>()
                        .Where(x => x.Key == model.ChatShow.Key)
                        .OrderBy(x => x.CreatedTime)
                        .ToListAsync();

                    foreach (var value in values)
                    {
                        model.messages.Add(value);
                    }
                };
            }
        };
    }

    private void InitializeComponent()
    {
        AvaloniaXamlLoader.Load(this);
    }

    protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
    {
        base.OnAttachedToVisualTree(e);
        var topLevel = TopLevel.GetTopLevel(this);
        _manager = new WindowNotificationManager(topLevel) { MaxItems = 3 };
    }

    private void Close_OnClick(object? sender, RoutedEventArgs e)
    {
        var window = TopLevel.GetTopLevel(this) as Window;
        window.ShowInTaskbar = false;
        window.WindowState = WindowState.Minimized;
    }

    private SendChatViewModel ViewModel => DataContext as SendChatViewModel;

    private void Thumb_OnDragDelta(object? sender, VectorEventArgs e)
    {
        var thumb = (Thumb)sender;
        var wrapPanel = (WrapPanel)thumb.Parent;
        wrapPanel.Width += e.Vector.X;
        wrapPanel.Height += e.Vector.Y;
    }

    private void SendBorder_OnPointerEntered(object? sender, PointerEventArgs e)
    {
    }

    private async void SendMessage_OnClick(object? sender, RoutedEventArgs e)
    {
        await SendMessageAsync();
    }

    private void Minimize_OnClick(object? sender, RoutedEventArgs e)
    {
        var window = TopLevel.GetTopLevel(this) as Window;
        window.WindowState = WindowState.Minimized;
    }

    private void Maximize_OnClick(object? sender, RoutedEventArgs e)
    {
        var window = TopLevel.GetTopLevel(this) as Window;
        window.WindowState = window.WindowState switch
        {
            WindowState.Maximized => WindowState.Normal,
            WindowState.Normal => WindowState.Maximized,
            _ => window.WindowState
        };
    }

    private async void SendTextBox_OnKeyDown(object? sender, KeyEventArgs e)
    {
        if (e.Key == Key.Enter)
        {
            await SendMessageAsync();
        }
    }

    private async Task SendMessageAsync()
    {
        try
        {
            if (ViewModel?.ChatShow?.Key == null)
            {
                _manager?.Show(new Notification("提示", "請先選擇一個對話方塊!", NotificationType.Warning));
                return;
            }

            // 獲取當前程式集 assets圖片
            // var uri = new Uri("avares://ChatGPT/Assets/avatar.png");
            // // 透過uri獲取Stream
            // var bitmap = new Bitmap(AvaloniaLocator.Current.GetService<IAssetLoader>().Open(uri));

            var model = new ChatMessage
            {
                ChatShowKey = ViewModel.ChatShow.Key,
                // Avatar = bitmap,
                Title = "token",
                Content = ViewModel.Message,
                CreatedTime = DateTime.Now,
                IsChatGPT = false
            };

            // 新增到訊息列表
            ViewModel.messages.Add(model);

            // 清空輸入框
            ViewModel.Message = string.Empty;

            // 獲取訊息記錄用於AI聯絡上下文分析 來自Token的程式碼
            var message = ViewModel.messages
                .OrderByDescending(x => x.CreatedTime) // 拿到最近的5條訊息
                .Take(5)
                .OrderBy(x => x.CreatedTime) // 按時間排序
                .Select(x => x.IsChatGPT
                    ? new
                    {
                        role = "assistant",
                        content = x.Content
                    }
                    : new
                    {
                        role = "user",
                        content = x.Content
                    }
                )
                .ToList();

            // 請求ChatGpt 3.5最新模型 來自Token的程式碼
            var responseMessage = await http.PostAsJsonAsync("https://api.openai.com/v1/chat/completions", new
            {
                model = "gpt-3.5-turbo",
                temperature = 0,
                max_tokens = 2560,
                user = "token",
                messages = message
            });

            // 獲取返回的訊息 來自Token的程式碼
            var response = await responseMessage.Content.ReadFromJsonAsync<GetChatGPTDto>();

            // 獲取當前程式集 assets圖片
            // uri = new Uri("avares://ChatGPT/Assets/chatgpt.ico");

            var chatGptMessage = new ChatMessage
            {
                ChatShowKey = ViewModel.ChatShow.Key,
                // Avatar = new Bitmap(AvaloniaLocator.Current.GetService<IAssetLoader>().Open(uri)),
                Title = "ChatGPT",
                Content = response.choices[0].message.content,
                IsChatGPT = true,
                CreatedTime = DateTime.Now
            };
            // 新增到訊息列表 來自Token的程式碼
            ViewModel.messages.Add(chatGptMessage);

            var freeSql = MainApp.GetService<IFreeSql>();
            await freeSql
                .Insert(model)
                .ExecuteAffrowsAsync();

            await freeSql
                .Insert(chatGptMessage)
                .ExecuteAffrowsAsync();
        }
        catch (Exception e)
        {
            // 異常處理 
            _manager?.Show(new Notification("提示", "在請求AI服務時出現錯誤!請聯絡管理員!", NotificationType.Error));
        }
    }
}

實現傳送前需要將之前的最近五條資料得到跟隨當前資料一塊傳送,為了讓其ChatGpt可以聯絡上下文,這樣回覆的內容更準確,聊天的資料使用Sqlite本地儲存,為了輕量使用ORM採用FreeSql,介面仿製微信的百分之八十的還原度,基本上一直,而且原始碼完全開源。

來自token的分享

GitHub開源地址: https://github.com/239573049/ChatGpt.Desktop

相關文章