C#でChatGPTをローカルで動かしてみる(本当はphi-3-mini)

# Phi-3-miniについて
AIについて詳しい方なら知っているとは思いますが、Microsoftが作り上げたローカルで動かせるChatGPTがphi-3-miniになります。
ChatGPTモデルと異なりめちゃくちゃ軽量なので、ちょっと強めのGPU(言うてもGTX1650くらいでいい)を積んだPCなら余裕で扱える品物になります。
そしてなんと言っても無料で使えるというのがうれしい限りですね。
これを使ってWinUI3でローカルで動くChatGPTを作っていきます。

# まだ未完成
アプリのほうですが、まだ未完成です。
AIを使って質問を回答してくれる大部分の処理はできたのですが、UI部分がもっとこうであればいいのになぁみたいなところが詰めきれていません。
理想は以下のリンクのようなソフトウェアになります。

https://apps.microsoft.com/detail/9NW24N9W33C9?hl=neutral&gl=JP&ocid=pdpshare

WinUI3で作ってるようですがデザインめちゃくちゃかっこよくね？？？
しかし、これはChatGPTのAPIを使って行うものなので別途お金がかかる感じのものです。
自分はこいつをローカルで無料で動かしたいわけです。
ということで作っていきましょう。

# 作成方法はMicrosoftに載ってある
作り方はMicrosoftの記事に載ってあるので誰でも簡単に作ることができます。

https://learn.microsoft.com/ja-jp/windows/ai/models/get-started-models-genai

同じの作っても仕方ないので、MVVMで作っていきます。

# モデルをダウンロード
C#でAIを使う場合、ONNXモデルを使うことになります。
これはPythonとかで作ったAIモデルをC++だとかJavaだとかC#だとかで使えるようにした共通フォーマットです。
こいつを使うことでAIの知識なくても、またPythonを覚えなくても何ら問題なくAIを使ったアプリを作ることができるのです。

ということでそのONNXモデルを以下のリンクからダウンロードします。
今回は一番軽量のphi-3-miniのモデルですが、つよつよGPU積んでる人はMediumでもVisonでも何でもよいです。

https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-onnx/tree/main/directml/directml-int4-awq-block-128

ダウンロードしたら`directml`のフォルダをプロジェクトの`Models`フォルダを作り入れておきます。
プロパティの「出力ディレクトリにコピーする」を「新しい場合はコピーする」に変えるのを忘れないでください(すべてのファイル)

# AIの処理を書く
`Services`フォルダを新しく作りChatAIを行う`ChatService.cs`を作成しました。

```csharp
using Microsoft.ML.OnnxRuntimeGenAI;
using Models;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;

namespace Services
{
    public class ChatService
    {
        public AIModel ModelLoadService(string modelDir)
        {
            var model = new Model(modelDir);
            var tokenizer = new Tokenizer(model);

            return new AIModel{
                Model = model,
                Tokenizer = tokenizer };
        }

        public async IAsyncEnumerable<string> InferStreaming(string prompt,AIModel aiModel)
        {
            if (aiModel == null)
            {
                throw new InvalidOperationException("Model is not ready");
            }
            var generatorParams = new GeneratorParams(aiModel.Model);

            var sequences = aiModel.Tokenizer.Encode(prompt);

            generatorParams.SetSearchOption("max_length", 2048);
            generatorParams.SetInputSequences(sequences);
            generatorParams.TryGraphCaptureWithMaxBatchSize(1);

            using var tokenizerStream = aiModel.Tokenizer.CreateStream();
            using var generator = new Generator(aiModel.Model, generatorParams);
            StringBuilder stringBuilder = new();
            while (!generator.IsDone())
            {
                string part;
                try
                {
                    // この辺で答えを構築しているのだと思われる
                    await Task.Delay(10).ConfigureAwait(false);
                    generator.ComputeLogits();
                    generator.GenerateNextToken();
                    part = tokenizerStream.Decode(generator.GetSequence(0)[^1]);
                    stringBuilder.Append(part);
                    if (stringBuilder.ToString().Contains("
") || stringBuilder.ToString().Contains("
") || stringBuilder.ToString().Contains("
"))
                    {
                        continue;
                        //break;
                    }
                }
                catch (Exception ex)
                {
                    Debug.WriteLine(ex);
                    break;
                }
                // 答えを推論したもの細切れになって送られる
                yield return part;
            }
        }
    }
}
```
やっていることは上記のMicrosoftの記事と同様です。
1. ViewModelからモデルのパスが渡されるとAIモデルをロードしてそれを返すサービスと
2. プロンプト(質問とか)とAIモデルが渡されるとそれに対応した答えが、細切れになって渡されるサービスになります。

# ViewModel
UIの動作をハンドリングして`Services`に処理を渡します。
どのサービスを使用するか予め保持しておくことがMVVMでは重要。
```csharp
public partial class HomeViewModel:ObservableObject
{
    // Chatを表示するためのコレクション、ここに自分の質問や答えを入れまくり、表示する
    [ObservableProperty]
    public ObservableCollection<ChatDetail> chatData;

    // MarkdownTextBlockを使うのでConfigを保持
    public MarkdownConfig MConfig { get; set; }

    MarkdownConfig markdownConfig;

    // 上記のChat用Serviceを保持
    ChatService chatService;
    // UIフレームワークではDispatcherという処理を伝える役目が必要
    public Microsoft.UI.Dispatching.DispatcherQueue TheDispatcher { get; set; }

    // 読み込んだAIモデルを保持
    public AIModel AIModel { get; set; }

    // モデルのパス(モデルのダウンロードで取り込んだモデルまでのパスを指定)
    private readonly string modelDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"Models\directml\directml-int4-awq-block-128");

    // 質問のテキスト
    [ObservableProperty]
    public string promptText;
    // 反応のテキスト(例えばモデルを読み込みましたとか失敗しましたとか)
    [ObservableProperty]
    public string responseText;

    public HomeViewModel()
    {
        // コンストラクタで表示するデータを初期化
        ChatData = new ObservableCollection<ChatDetail>();
        // Serviceを取得
        chatService = new ChatService();

        // MarkdownTextBlockを使うための処理
        markdownConfig = MarkdownConfig.Default;
        MConfig = markdownConfig;
    }

    // ボタンが押されたら質問に答える
    [RelayCommand]
    async Task OnCreateText()
    {
        // まず質問の内容を詰める
        ChatData.Add(new ChatDetail 
        {
            User = 1, //User1が自分
            DateTime = DateTime.Now,
            Message = PromptText, //質問した内容
            MarkdownConfig = MarkdownConfig.Default, //MarkdownTextBlockの表示に必要
        });
        // AIの回答を詰める
        ChatData.Add(new ChatDetail
        {
            User = 0, //User0がAI
            DateTime = DateTime.Now,
            Message = "", //最初に空文字後から回答をどんどん追加していく方式
            MarkdownConfig = MarkdownConfig.Default,
        });
        if (AIModel != null)
        {
            // AIが読み取れるプロンプトにするため加工
            var systemPrompt = "You are a helpful assistant.";
            var userPrompt = PromptText;

            // このプロンプトの加工時前回の質問も追加すると、それを反映させて質問できる(今回はしてない)
            var prompt = $@"
{systemPrompt}

{userPrompt}

";

            var tempstring = string.Empty;

            await foreach (var part in chatService.InferStreaming(prompt, AIModel))
            {
                // MarkDownのCodeBlockで意味もなく改行してしまう不具合があるのでそれを防止するため、
                // 2回連続改行を続けると1つを削除して1回の改行のみとして扱うための処理
                if (part.Contains("\n"))
                {
                    tempstring += part;
                    if (tempstring.Contains("\n\n"))
                    {
                        tempstring = string.Empty;
                        continue;
                    }
                }
                // ObservableCollectionのメッセージに返答の文字を追加していく
                ChatData.Last().Message += part;
            }
        }
        else
        {
            await Task.Run(() =>
            {
                TheDispatcher.TryEnqueue(() =>
                {
                    // 反応テキストにモデルがロードされていないと表示
                    ResponseText = "Model is Not Loading";
                });
            });
        }
    }
    // 画面がロードされたときに呼び出されるモデルをロードするもの
    [RelayCommand]
    public Task InitializeModelAsync()
    {
        if (AIModel == null)
        {
            // ロード中と反応テキストに表示
            ResponseText = "Loading model....";
            Task.Run(() =>
            {
                // 時間を測る
                var sw = Stopwatch.StartNew();
                AIModel = chatService.ModelLoadService(modelDir);
                sw.Stop();
                TheDispatcher.TryEnqueue(() =>
                {
                    // モデルを読み込むのにかかった時間を表示
                    ResponseText = $"Model loading took {sw.ElapsedMilliseconds} ms";
                });
            });
        }
        return Task.CompletedTask;
    }
}
```

`ObservableCollection`の答えの内容はどんどん追加されていくのですが、普通のUIだと追加されたとき再描画の通知がいかないので、`ChatDetail`のプロパティを`Observable`化しないといけない。
今回は`MVVM Toolkit`を使い簡単に実装
```csharp
public partial class ChatDetail : ObservableObject
{
    public int User {  get; set; }

    public DateTime DateTime { get; set; }
    // MessageをObservable化
    [ObservableProperty]
    public string message;

    [ObservableProperty]
    public MarkdownConfig markdownConfig;
}
```

# UI
このChatAIですが、回答はプレーンテキストではなくマークダウンで返ってきます。
ですので前回MarkdownTextBlockの記事を書いたんですね。
ということでUIは以下のようになります。
```xml
<Page
    x:Class="Views.HomeView"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:converter="using:Converters"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:ex="using:AttachedProperty"
    xmlns:i="using:Microsoft.Xaml.Interactivity"
    xmlns:ic="using:Microsoft.Xaml.Interactions.Core"
    xmlns:local="using:Views"
    xmlns:m="using:Models"
    xmlns:markdown="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    xmlns:vm="using:ViewModels"
    Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
    mc:Ignorable="d"
    >
    <Page.Resources>
        <converter:User2AlignConverter x:Key="UserConverter" />
        <converter:ChildWidthConverter x:Key="ChildWithConverter" />
        <DataTemplate x:Key="ChatDetail" x:DataType="m:ChatDetail">
            <Grid
                Width="{Binding ElementName=ChatPanel, Path=(ex:SizeChageExtensions.DynamicWidth), Converter={StaticResource ChildWithConverter}}"
                Height="Auto"
                Margin="12"
                HorizontalAlignment="{Binding User, Converter={StaticResource UserConverter}}"
                Background="{ThemeResource SystemChromeWhiteColor}"
                >

                <StackPanel HorizontalAlignment="Stretch">
                    <markdown:MarkdownTextBlock
                        HorizontalAlignment="{Binding User, Converter={StaticResource UserConverter}}"
                        FontSize="{StaticResource BodyTextBlockFontSize}"
                        Text="{x:Bind Message, Mode=OneWay}"
                        />
                    <TextBlock
                        HorizontalAlignment="{Binding User, Converter={StaticResource UserConverter}}"
                        FontSize="{StaticResource CaptionTextBlockFontSize}"
                        Text="{x:Bind DateTime}"
                        />
                </StackPanel>
            </Grid>
        </DataTemplate>
    </Page.Resources>

    <i:Interaction.Behaviors>
        <ic:EventTriggerBehavior EventName="Loaded">
            <ic:InvokeCommandAction Command="{x:Bind viewModel.InitializeModelCommand}" />
        </ic:EventTriggerBehavior>
    </i:Interaction.Behaviors>

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="4*" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>
        <ListView
            x:Name="ChatPanel"
            Grid.Row="0"
            Width="Auto"
            Padding="12"
            HorizontalAlignment="Stretch"
            ex:SizeChageExtensions.SizeChangeEvent="True"
            ItemTemplate="{StaticResource ChatDetail}"
            ItemsSource="{x:Bind viewModel.ChatData, Mode=TwoWay}"
            SelectionMode="None"
            >
            <ListView.ItemsPanel>
                <ItemsPanelTemplate>
                    <ItemsStackPanel VerticalAlignment="Bottom" ItemsUpdatingScrollMode="KeepLastItemInView" />
                </ItemsPanelTemplate>
            </ListView.ItemsPanel>
        </ListView>
        <StackPanel
            Grid.Row="1"
            HorizontalAlignment="Stretch"
            VerticalAlignment="Stretch"
            >
            <TextBox
                Margin="12"
                HorizontalAlignment="Stretch"
                VerticalAlignment="Center"
                Text="{x:Bind viewModel.PromptText, Mode=TwoWay}"
                />
            <Button
                Margin="12"
                HorizontalAlignment="Right"
                VerticalAlignment="Center"
                Command="{x:Bind viewModel.CreateTextCommand}"
                Content="Button"
                />
            <TextBlock Text="{x:Bind viewModel.ResponseText, Mode=OneWay}" />
            <TextBlock Text="{Binding ElementName=ChatPanel, Path=(ex:SizeChageExtensions.DynamicWidth), Mode=OneWay}" />
        </StackPanel>
    </Grid>
</Page>
```
チャット部分の表示に関しては`<Page.Resource>`の`DataTemplate`に記述してあるので参考にしてください。

とりあえず駆け足ですが、こういう形で実装しました。