はじめに
この記事は2章の続きです。
まだ読んでいない人は2章から読まれることを推奨します。
この章では1章2章で説明したことを踏まえて実際にチャットアプリを実装していこうと思います。
記事を通じて疑問点があれば、ぜひフィードバックをください。
1章 設計説明編
2章 AppUI編
3章 MVVM実装編
4章 アプリ実装編
5章 Middleware編
仕様を考える
まずは仕様を考えていきましょう。
主要な機能は以下の通りです。
- ユーザーがメッセージを入力する
- 送信するとClaudeの返信が来る
- 返信に対してさらにユーザーがメッセージを入力する
- 送信すると... (以下繰り返し)
今回はかなり単純なものでむしろ考える必要もないくらいですが、規模の大きいアプリの場合ここらへんを整理しておくことは重要だと思います。
なぜならば仕様が整理できていないと設計もできないからですね。
次は実際に設計に移っていきましょう。
設計を考える
1章で説明した通り、MVVM×Reduxを採用して設計していこうと思います。
概念整理
設計するにあたって、仕様から登場する概念を整理していきます。
Viewに必要なもの
まずはViewに何が必要か。
- メッセージを入力するUI
- メッセージを送信するボタン
- システムメッセージを入力するUI
- temperatureを入力できるスライダー
- Claudeからのメッセージを出力するUI
メッセージを入出力するUIに関しては複数行あるテキストボックスのようなものを想定しています。
ViewModelに必要なもの
次にViewModelを整理していきましょう。
Viewと1対1で対応するようにします。
- 入力されたメッセージをセットするCommand
- メッセージを送信するCommand
- 入力されたシステムメッセージをセットするCommand
- 入力されたtemperatureをセットするCommand
- Claudeから出力されたメッセージを保持するプロパティ
Modelに必要なもの
最後にModelを整理します。
ここはModelというよりは、ReduxのStateとして何が必要かと考えるほうが今回の場合適切かもしれません。
- 入力されたメッセージ
- システムメッセージ
- temperature
- Claudeから出力されたメッセージ
また、Claudiaを使う際にAPIKeyとAnthropicが必要なためこれも保持しておきます。
- ApiKey
- Anthropicのインスタンス
クラス設計
整理した概念から実際にクラス図を作成していこうと思います。
今回は各レイヤーでテストを書きたいと思っているのでいい感じにインターフェースを切り出します。
正しいかどうかを判断することは難しいと思います。こんな感じになったんだなーと読み飛ばしてもらって構いません。
レイヤーで分けると
- EntryPoint
- View
- ViewModel
- Redux Store
- AppState (Model)
の5つになりそうです。
整理を参考に要素を追加してあげると...
こんな感じになりそうです。
AppUIも含めて最終的に以下のようになりました。
これに従って実際に実装していきたいと思います。
実装
開発環境
Unity 2022.3.21f1
AppUI 1.0.5
Claudia 1.0.0
NuGetForUnity 4.0.2
セットアップ
AppUIのセットアップ方法は2章で説明していますので割愛します。
今回はClaude APIを使用したいのでこれをサポートしたClaudiaを入れていきます。
ClaudiaはNuGet
のパッケージなのでまずはNuGetForUnity
をセットアップする必要があります。
NuGetForUnityのセットアップ
NuGetForUnity
はNuGetのパッケージをUnityへ導入できるようにするエディタ拡張です。
セットアップ方法は簡単で、
Package Manage → 左上の+ボタン → Add package from git URL
から
https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity
を入力することできます。
Claudiaのセットアップ
次に導入したNuGetForUnity
を利用してClaudiaを入れていきましょう。
NuGet → manage NuGet Packages
をクリック
NuGetForUnity
のウィンドウが開いたらClaudia
で検索してください。
おそらく一番上が今回欲しいパッケージClaudia
になっていると思います。
これをインストールすればセットアップは完了です。
次から実装に移っていきます。
View
まずはUXMLから実装します。
以下のようにファイルを作成してください。
Assets以下であればどこでも構いません。
+ Claudia
+ └── SampleResources
+ ├── Styles
+ │ └── MainPage.uss
+ └── Views
+ └── MainPage.uxml
MainPage.uxmlに記述していきます。
UIはClaudia Blazorのサンプルを参考にしています。
https://github.com/Cysharp/Claudia/blob/main/sandbox/BlazorApp1/Components/Pages/Home.razor
適宜変えてもらって構いません。
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:appui="Unity.AppUI.UI" xsi="http://www.w3.org/2001/XMLSchema-instance"
editor="UnityEditor.UIElements" noNamespaceSchemaLocation="../UIElementsSchema/UIElements.xsd"
editor-extension-mode="False">
<appui:Panel class="claudia-root-panel" scale="large">
<ui:VisualElement class="claudia-root">
<appui:Text class="claudia-title" text="UIToolKit Claudia"/>
<ui:VisualElement class="claudia-main-content">
<ui:VisualElement class="content">
<appui:TextArea class="claudia-text-area"
is-read-only="true"
value="Claudia is a simple UI Toolkit application that demonstrates the use of MVVM and Redux patterns."
/>
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement class="claudia-control">
<ui:VisualElement class="content">
<ui:VisualElement class="claudia-temperature">
<appui:Text class="claudia-temperature-title" text="Temperature"/>
<appui:SliderFloat class="claudia-temperature-slider" value="1.0" low-value="0"
high-value="1.0" format-string="F2"/>
</ui:VisualElement>
<ui:VisualElement class="claudia-system">
<appui:Text class="claudia-system-title" text="System"/>
<appui:TextArea class="claudia-system-text-area"/>
</ui:VisualElement>
</ui:VisualElement>
<ui:VisualElement class="claudia-form">
<appui:TextArea class="claudia-form-text-area"/>
<appui:Button class="claudia-form-button" title="Send"/>
</ui:VisualElement>
</ui:VisualElement>
</ui:VisualElement>
</appui:Panel>
</ui:UXML>
次にMainPage.uxmlを記述していきます。
MainPage {
width: 100%;
height: 100%;
}
.claudia-root {
width: 100%;
height: 100%;
padding: var(--appui-spacing-150);
display: flex;
flex-direction: column;
}
.claudia-title {
flex-grow: 0;
flex-shrink: 0;
-unity-text-align: middle-center;
}
.claudia-main-content {
height: 100%;
width: 100%;
flex-grow: 1;
flex-shrink: 1;
padding: var(--appui-spacing-150);
display: flex;
flex-direction: column;
}
.claudia-main-content .content {
height: 100%;
background-color: white;
padding: var(--appui-spacing-25);
border-radius: var(--appui-border-radius-sm);
}
.claudia-text-area {
flex-grow: 1;
}
.claudia-control {
flex-grow: 0;
width: 100%;
background-color: white;
padding: var(--appui-spacing-50);
border-bottom-left-radius: var(--appui-border-radius-sm);
border-bottom-right-radius: var(--appui-border-radius-sm);
}
.claudia-control .content {
display: flex;
width: 100%;
flex-direction: row;
}
.claudia-temperature {
flex-grow: 0;
flex-shrink: 0;
padding: var(--appui-spacing-25);
margin-right: var(--appui-spacing-100);
}
.claudia-temperature-title {
color: black;
}
.claudia-temperature-slider .unity-text-element {
color: black;
}
.claudia-system-title {
color: black;
}
.claudia-system {
flex-grow: 1;
flex-shrink: 1;
}
.claudia-system-text-area {
min-height: auto;
height: 80%;
}
.claudia-form {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
}
.claudia-form-text-area {
flex-grow: 1;
flex-shrink: 1;
min-height: auto;
max-height: 70%;
margin-right: var(--appui-spacing-25);
}
.claudia-form-button {
flex-grow: 0;
flex-shrink: 0;
}
.appui-textarea__resize-handle {
display: none;
}
これで一旦Viewの実装は終わりです。
UXMLのテスト
次に作ったUXMLを実際にテストしていきます。
UIDocumentをScene上に置けばできますが、PlayModeに入らないと確認できないため少しだるいです。
そこで2章で説明したStoryBookを使用しましょう。
StoryBookを使用することでEditor上にて確認できます。
次のようにファイルを作成してください。
Claudia
├── SampleResources
│ ├── Styles
│ │ └── MainPage.uss
│ └── Views
│ └── MainPage.uxml
+ └── Scripts
+ └── Tests
+ └── Editor
+ ├── MainPage.stories.cs
+ └── ClaudiaApp.Test.Editor.asmdef
ClaudiaApp.Test.Editor.asmdef
はUnity上から作成することができます。
Assets → Create → AssemblyDefinition
と辿ってください。
作成できたら、アセンブリファイルを下図のように設定してください。
次にMainPage.stories.csを記述していきます。
各々の環境によりGUIDは変わるため、調べて変えてください。
using System;
using Unity.AppUI.Editor;
using UnityEditor;
using UnityEngine.UIElements;
namespace ClaudiaApp.Tests.Editor
{
public class MainPage : StoryBookPage
{
private const string MainPageUxmlGuid = "自分のMainPage.uxmlのGUIDを調べる";
private const string MainPageUssGuid = "自分のMainPage.ussのGUIDを調べる";
public MainPage()
{
m_Stories.Add(new StoryBookStory("Main", () =>
{
var element = new VisualElement();
var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
AssetDatabase.GUIDToAssetPath(MainPageUxmlGuid));
tree.CloneTree(element);
// StoryBookの表示部分はPanel下に置かれる。
// MainPage.uxmlもrootがPanelであるため、その下のVisualElementを取り出している
var root = element.Q<VisualElement>(className: "claudia-root");
root.styleSheets.Add(
AssetDatabase.LoadAssetAtPath<StyleSheet>(AssetDatabase.GUIDToAssetPath(MainPageUssGuid)));
return root;
}));
}
public override string displayName => "Claudia-MainPage";
public override Type componentType => null;
}
}
ここまでできたらStoryBookを確認してみてください。
作成したUXMLが描画されているはずです。
Viewの実装
次にViewを実装していきます。
以下のようにファイルを作成してください。
Claudia
├── SampleResources
│ ├── Styles
│ │ └── MainPage.uss
│ └── Views
│ └── MainPage.uxml
└── Scripts
+ ├── Runtime
+ │ ├── ViewModels
+ │ │ ├── IMainViewModel.cs
+ │ │ └── MockMainViewModel.cs
+ │ ├── View
+ │ │ └── MainPage.cs
+ │ └── ClaudiaUiToolKit.asmdef
└── Tests
└── Editor
├── MainPage.stories.cs
└── Tests.asmdef
ClaudiaUiToolKit.asmdef
の設定は以下のようにしてください。
IMainViewModel.cs
を記述していきます。
using System.Collections.Generic;
using Claudia;
using Unity.AppUI.MVVM;
namespace ClaudiaApp.ViewModels
{
public interface IMainViewModel: INotifyPropertyChanged
{
public RelayCommand<float> SetTemperatureCommand { get; }
public RelayCommand<string> SetSystemMessageCommand { get; }
public RelayCommand<string> SetInputMessageCommand { get; }
public AsyncRelayCommand SendMessageCommand { get; }
public List<Message> ChatMessage { get; set; }
}
}
次にMockMainViewModel.cs
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using Claudia;
using Unity.AppUI.MVVM;
using UnityEngine;
namespace ClaudiaApp.ViewModels
{
public class MockMainViewModel : IMainViewModel
{
public MockMainViewModel()
{
ChatMessage = new List<Message>();
SetTemperatureCommand = new RelayCommand<float>(v => Debug.Log(v));
SetSystemMessageCommand = new RelayCommand<string>(Debug.Log);
SetInputMessageCommand = new RelayCommand<string>(Debug.Log);
SendMessageCommand = new AsyncRelayCommand(async () =>
{
await Task.Delay(1000);
ChatMessage.Add(new Message { Role = Roles.User, Content = "Message sent" });
OnPropertyChanged(nameof(ChatMessage));
});
}
public RelayCommand<float> SetTemperatureCommand { get; }
public RelayCommand<string> SetSystemMessageCommand { get; }
public RelayCommand<string> SetInputMessageCommand { get; }
public AsyncRelayCommand SendMessageCommand { get; }
public List<Message> ChatMessage { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}
最後にMainPage.cs
using System.ComponentModel;
using System.Linq;
using ClaudiaApp.ViewModels;
using Unity.AppUI.UI;
using UnityEngine.UIElements;
using Button = Unity.AppUI.UI.Button;
namespace ClaudiaApp.View
{
public class MainPage : VisualElement
{
private readonly IMainViewModel _model;
private readonly VisualTreeAsset _template;
private TextArea _inputMessage;
private TextArea _outputMessage;
private Button _sendButton;
private SliderFloat _slider;
private TextArea _systemMessage;
public MainPage(IMainViewModel model)
{
_model = model;
InitializeComponent();
_model.PropertyChanged += OnPropertyChanged;
}
public MainPage(VisualTreeAsset template, IMainViewModel model)
{
_template = template;
_model = model;
InitializeComponent();
_model.PropertyChanged += OnPropertyChanged;
}
private void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(_model.ChatMessage))
{
var texts = _model.ChatMessage.Select(x => x.Content[0].Text);
_outputMessage.value = string.Join("\n", texts);
}
}
private void InitializeComponent()
{
if (_template == null)
{
var template = ClaudiaAppBuilder.Instance.MainPageTemplate;
template.CloneTree(this);
}
else
{
_template.CloneTree(this);
}
_slider = this.Q<SliderFloat>();
_systemMessage = this.Q<TextArea>(className: "claudia-system-text-area");
_inputMessage = this.Q<TextArea>(className: "claudia-form-text-area");
_outputMessage = this.Q<TextArea>(className: "claudia-text-area");
_sendButton = this.Q<Button>(className: "claudia-form-button");
_slider.RegisterValueChangingCallback(OnSliderValueChanged);
_systemMessage.RegisterValueChangingCallback(OnSystemMessageChanged);
_inputMessage.RegisterValueChangingCallback(OnInputMessageChanged);
_sendButton.clicked += OnSendButtonClicked;
}
private void OnInputMessageChanged(ChangingEvent<string> evt)
{
if (!string.Equals(evt.newValue, evt.previousValue)) _model.SetInputMessageCommand.Execute(evt.newValue);
}
private void OnSystemMessageChanged(ChangingEvent<string> evt)
{
if (!string.Equals(evt.newValue, evt.previousValue)) _model.SetSystemMessageCommand.Execute(evt.newValue);
}
private void OnSliderValueChanged(ChangingEvent<float> evt)
{
if (!evt.newValue.Equals(evt.previousValue)) _model.SetTemperatureCommand.Execute(evt.newValue);
}
private void OnSendButtonClicked()
{
_model.SendMessageCommand.Execute();
}
}
}
これでViewのスクリプトは終わりです。
何の解説もせずにここまで来ましたが、ほぼサンプルと同じつくりになっています。詳しくは2章を読んでください。
Viewのテスト
実際に動作するかStoryBookで表示させてみましょう。
MainPage.stories.cs
に加筆します。
using System;
using ClaudiaApp.ViewModels;
using Unity.AppUI.Editor;
using UnityEditor;
using UnityEngine.UIElements;
namespace ClaudiaApp.Tests.Editor
{
public class MainPage : StoryBookPage
{
private const string MainPageUxmlGuid = "77a40617b8f8e6844a325bad240d9287";
private const string MainPageUssGuid = "9432707a9a7941d48d41636a0df4e3bc";
public MainPage()
{
m_Stories.Add(new StoryBookStory("Main", () =>
{
var element = new VisualElement();
var tree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
AssetDatabase.GUIDToAssetPath(MainPageUxmlGuid));
tree.CloneTree(element);
var root = element.Q<VisualElement>(className: "claudia-root");
root.styleSheets.Add(
AssetDatabase.LoadAssetAtPath<StyleSheet>(AssetDatabase.GUIDToAssetPath(MainPageUssGuid)));
return root;
}));
+ m_Stories.Add(new StoryBookStory("Main with DataBinding", () =>
+ {
+ var template = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
+ AssetDatabase.GUIDToAssetPath(MainPageUxmlGuid));
+ var style = AssetDatabase.LoadAssetAtPath<StyleSheet>(AssetDatabase.GUIDToAssetPath(MainPageUssGuid));
+ var viewModel = new MockMainViewModel();
+ var page = new View.MainPage(template, viewModel);
+ var root = page.Q<VisualElement>(className: "claudia-root");
+ root.styleSheets.Add(style);
+
+ return root;
+ }));
}
public override string displayName => "Claudia-MainPage";
public override Type componentType => null;
}
}
これでViewのテストができるはずです。
本来はViewModelが必要ですが、テスト用のMockViewModelをインスタンス化し注入しています。
StoryBookを見てみましょう。
以下のすべてが満たされていればテスト成功です。
- TextAreaに文字を入力したときにLogがでる
- Sliderを変更したときにLogがでる
- ボタンを押下すると1秒後にメッセージが出力されるか
成功していそうですね!
これにてViewの実装は終わりです。
Modelの実装
次にModelを実装していきます。
先にViewModelを実装してもよいのですが、少し複雑になるので後に回します。
次のようにファイルを作成してください。
Claudia
├── SampleResources
│ ├── Styles
│ │ └── MainPage.uss
│ └── Views
│ └── MainPage.uxml
└── Scripts
├── Runtime
│ ├── Models
+ │ │ ├── Actions.cs
+ │ │ ├── AppState.cs
+ │ │ └── Reducers.cs
│ ├── ViewModels
│ │ ├── IMainViewModel.cs
│ │ └── MockMainViewModel.cs
│ ├── View
│ │ └── MainPage.cs
│ └── ClaudiaUiToolKit.asmdef
└── Tests
├── Editor
│ ├── ClaudiaApp.Test.Editor.asmdef
│ └── MainPage.stories.cs
└── Runtime
+ ├── Models
+ │ └── ReducerTest.cs
+ └── ClaudiaApp.Test.asmdef
ClaudiaApp.Test.asmdef
は作成したら以下の設定にしてください。
Actions.cs
を記述していきます。
namespace ClaudiaApp.Models
{
public static class Actions
{
public const string SliceName = "app";
public const string SetTemperature = SliceName + "/SetTemparature";
public const string SetInputMessage = SliceName + "/SetInputMessage";
public const string SetSystemString = SliceName + "/SetSystemString";
public const string SetChatMessage = SliceName + "/SetChatMessage";
}
}
ここの文字列はなんでもよいです。Stateが不変であることを考えるとSet
という命名はすこしよくない気もしますが... 気持ち悪いと思った方は自由に変えてもらって構いません。
AppState.cs
を記述していきます。
using System;
using System.Collections.Generic;
using Claudia;
using UnityEngine;
namespace ClaudiaApp.Models
{
[Serializable]
public record AppState
{
[SerializeField] public float temperatureValue;
[SerializeField] public string inputMessage;
[SerializeField] public string systemString;
[SerializeField] public string apiKey;
[SerializeField] public Anthropic Anthropic = new()
{
ApiKey ="ClaudeのAPIKeyを入れる"
};
[SerializeField] public List<Message> ChatMessages = new();
}
}
APIKeyは残念ながら有料です。(ここで言うなよ)
以下の記事で詳しく説明していますので参照してください。
最後にReducers.cs
を記述していきます。
using System.Collections.Generic;
using Claudia;
using Unity.AppUI.Redux;
namespace ClaudiaApp.Models
{
public class Reducers
{
public static AppState SetTemperatureValueReducer(AppState state, Action<float> action)
{
return state with { temperatureValue = action.payload };
}
public static AppState SetInputMessageReducer(AppState state, Action<string> action)
{
return state with { inputMessage = action.payload };
}
public static AppState SetSystemStringReducer(AppState state, Action<string> action)
{
return state with { systemString = action.payload };
}
public static AppState SetChatMessage(AppState state, Action<List<Message>> action)
{
return state with { ChatMessages = action.payload };
}
}
}
Modelの実装はここでいったん終了です。
Modelのテスト
正常に動くかテストを書いていきます。
全てテストコードを書いてもよいですが、今回はロジック部分のReducerのみ書いていこうと思います。
ReducerTest.cs
を記述していきます。
using System.Collections.Generic;
using Claudia;
using ClaudiaApp.Models;
using NUnit.Framework;
using Unity.AppUI.Redux;
namespace ClaudiaApp.Tests.Models
{
[TestFixture]
public class ReducersTest
{
[Test]
public void ReducersTest_SetTemperatureValueReducer()
{
// Arrange
var state = new AppState();
var action = Store.CreateAction<float>(Actions.SetTemperature);
var temperature = 0.5f;
// Act
var newState = Reducers.SetTemperatureValueReducer(state, action.Invoke(temperature));
// Assert
Assert.That(newState.temperatureValue, Is.EqualTo(temperature));
}
[Test]
public void ReducersTest_SetInputMessageReducer()
{
// Arrange
var state = new AppState();
var action = Store.CreateAction<string>(Actions.SetInputMessage);
var inputMessage = "Input message";
// Act
var newState = Reducers.SetInputMessageReducer(state, action.Invoke(inputMessage));
// Assert
Assert.That(newState.inputMessage, Is.EqualTo(inputMessage));
}
[Test]
public void ReducersTest_SetSystemStringReducer()
{
// Arrange
var state = new AppState();
var action = Store.CreateAction<string>(Actions.SetSystemString);
var systemString = "System message";
// Act
var newState = Reducers.SetSystemStringReducer(state, action.Invoke(systemString));
// Assert
Assert.That(newState.systemString, Is.EqualTo(systemString));
}
[Test]
public void ReducersTest_SetChatMessage()
{
// Arrange
var state = new AppState();
var action = Store.CreateAction<List<Message>>(Actions.SetChatMessage);
var chatMessage = new List<Message> { new() { Content = "", Role = Roles.User } };
// Act
var newState = Reducers.SetChatMessage(state, action.Invoke(chatMessage));
// Assert
Assert.That(newState.ChatMessages, Is.EqualTo(chatMessage));
}
}
}
これでテストの実装は終わりです。
さて次に書いたコードを実際に動作させます。
Unityのツールバーから Window → General → TestRunner
をクリックしてテスト用のウィンドウを開くことができます。
全て緑色になれば成功です!
ViewModelの実装
MVVMのラストです。
ViewModelを実装していきます。
以下のようにファイルを作成してください。
Claudia
├── SampleResources
│ ├── Styles
│ └── Views
│ └── MainPage.uxml
└── Scripts
├── Runtime
│ ├── Models
│ │ ├── Actions.cs
│ │ ├── AppState.cs
│ │ └── Reducers.cs
│ ├── Services
+ │ │ ├── ILocalStorageService.cs
+ │ │ ├── IStoreService.cs
+ │ │ ├── MockStorageService.cs
+ │ │ └── StoreService.cs
│ ├── ViewModels
│ │ ├── IMainViewModel.cs
+ │ │ ├── MainViewModel.cs
│ │ └── MockMainViewModel.cs
│ ├── Vuiew
│ │ └── MainPage.cs
│ └── ClaudiaUiToolKit.asmdef
└── Tests
├── Editor
│ ├── ClaudiaApp.Test.Editor.asmdef
│ └── MainPage.stories.cs
└── Runtime
├── ViewModels
│ └── MainViewModelTest.cs
├── Models
│ └── ReducerTest.cs
└── ClaudiaApp.Test.asmdef
ILocalStorageService.cs
を記述していきます。
namespace ClaudiaApp.Services
{
public interface ILocalStorageService
{
T GetValue<T>(string key, T defaultValue = default);
void SetValue<T>(string key, T value);
}
}
IStoreService.cs
を記述していきます。
using Unity.AppUI.Redux;
namespace ClaudiaApp.Services
{
public interface IStoreService
{
Store Store { get; }
}
}
MockStorageService.cs
を記述していきます。
namespace ClaudiaApp.Services
{
public class MockStorageService : ILocalStorageService
{
public T GetValue<T>(string key, T defaultValue = default)
{
return defaultValue;
}
public void SetValue<T>(string key, T value)
{
}
}
}
StoreService.cs
を記述していきます。
using Unity.AppUI.Redux;
namespace ClaudiaApp.Services
{
public class StoreService : IStoreService
{
public StoreService()
{
Store = new Store();
}
public Store Store { get; }
}
}
最後にMainViewModel.cs
を記述していきます。
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Claudia;
using ClaudiaApp.Models;
using ClaudiaApp.Services;
using Unity.AppUI.MVVM;
using Unity.AppUI.Redux;
using UnityEngine;
namespace ClaudiaApp.ViewModels
{
public class MainViewModel : ObservableObject, IMainViewModel
{
private readonly ILocalStorageService _localStorageService;
private readonly IStoreService _storeService;
private readonly Unsubscriber _unSubscriber;
private List<Message> _chatMessage;
private bool _running;
public MainViewModel(IStoreService storeService, ILocalStorageService localService)
{
_storeService = storeService;
_localStorageService = localService;
SetTemperatureCommand = new RelayCommand<float>(SetTemperature);
SetSystemMessageCommand = new RelayCommand<string>(SetSystemMessage);
SetInputMessageCommand = new RelayCommand<string>(SetInputMessage);
SendMessageCommand = new AsyncRelayCommand(SendMessage);
var initialState = localService.GetValue(Actions.SliceName, new AppState());
_storeService.Store.CreateSlice(Actions.SliceName, initialState, builder =>
{
builder
.Add<float>(Actions.SetTemperature, Reducers.SetTemperatureValueReducer)
.Add<List<Message>>(Actions.SetChatMessage, Reducers.SetChatMessage)
.Add<string>(Actions.SetInputMessage, Reducers.SetInputMessageReducer)
.Add<string>(Actions.SetSystemString, Reducers.SetSystemStringReducer);
});
_chatMessage = initialState.ChatMessages;
_unSubscriber = storeService.Store.Subscribe<AppState>(Actions.SliceName, OnStateChanged);
App.shuttingDown += OnShuttingDown;
}
public List<Message> ChatMessage
{
get => _chatMessage;
set
{
_chatMessage = value;
OnPropertyChanged();
}
}
public RelayCommand<float> SetTemperatureCommand { get; }
public RelayCommand<string> SetSystemMessageCommand { get; }
public RelayCommand<string> SetInputMessageCommand { get; }
public AsyncRelayCommand SendMessageCommand { get; }
private void OnStateChanged(AppState state)
{
ChatMessage = state.ChatMessages;
}
private async Task SendMessage(CancellationToken token)
{
if (_running) return;
_running = true;
try
{
var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
var anthropic = state.Anthropic;
ChatMessage.Add(new Message { Role = Roles.User, Content = state.inputMessage });
var stream = anthropic.Messages.CreateStreamAsync(new MessageRequest
{
Model = Claudia.Models.Claude3Opus,
MaxTokens = 1024,
Temperature = state.temperatureValue,
System = string.IsNullOrEmpty(state.systemString) ? null : state.systemString,
Messages = state.ChatMessages.ToArray()
}, cancellationToken: token);
var currentMessage = new Message
{
Role = Roles.Assistant,
Content = ""
};
ChatMessage.Add(currentMessage);
await foreach (var messageStreamEvent in stream)
if (messageStreamEvent is ContentBlockDelta content)
{
currentMessage.Content[0].Text += content.Delta.Text;
_storeService.Store.Dispatch(Actions.SetChatMessage, ChatMessage);
}
}
catch (Exception e)
{
Debug.Log(e);
}
finally
{
_running = false;
}
}
private void SetInputMessage(string message)
{
_storeService.Store.Dispatch(Actions.SetInputMessage, message);
}
private void SetSystemMessage(string message)
{
_storeService.Store.Dispatch(Actions.SetSystemString, message);
}
private void SetTemperature(float temperature)
{
_storeService.Store.Dispatch(Actions.SetTemperature, temperature);
}
private void OnShuttingDown()
{
_localStorageService.SetValue(Actions.SliceName, _storeService.Store.GetState<AppState>(Actions.SliceName));
App.shuttingDown -= OnShuttingDown;
_unSubscriber?.Invoke();
}
}
}
本来Modelで実装すべきロジック部分をModelがStateに変わった都合でViewModelに移動しています。
希望ではRedux Middlewareに実装したいところですがAppUIが対応していないためやむをえません。
MVVM設計パターンからは逸脱していないので許容です。
いつかRedux Middlewareを実装したいところです。
さて、ViewModelの実装が終わったので、実際に動くかどうかのテストをしていきましょう
ViewModelのテスト
まずはテストコードを書いていきます。
MainViewModelTest.cs
に以下を記述してください。
using System.Collections;
using System.Threading.Tasks;
using ClaudiaApp.Models;
using ClaudiaApp.Services;
using ClaudiaApp.ViewModels;
using NUnit.Framework;
using UnityEngine.TestTools;
namespace ClaudiaApp.Tests.ViewModels
{
[TestFixture]
public class MainViewModelTest
{
[SetUp]
public void SetUp()
{
_storeService = new StoreService();
_localStorageService = new MockStorageService();
_viewModel = new MainViewModel(_storeService, _localStorageService);
}
private IStoreService _storeService;
private ILocalStorageService _localStorageService;
private IMainViewModel _viewModel;
[Test]
public void MainViewModelTest_SetTemperatureCommand()
{
// Arrange
var temperature = 25.0f;
// Act
_viewModel.SetTemperatureCommand.Execute(temperature);
// Assert
var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
Assert.AreEqual(temperature, state.temperatureValue);
}
[Test]
public void MainViewModelTest_SetSystemMessageCommand()
{
// Arrange
var systemMessage = "System message";
// Act
_viewModel.SetSystemMessageCommand.Execute(systemMessage);
// Assert
var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
Assert.AreEqual(systemMessage, state.systemString);
}
[Test]
public void MainViewModelTest_SetInputMessageCommand()
{
// Arrange
var inputMessage = "Input message";
// Act
_viewModel.SetInputMessageCommand.Execute(inputMessage);
// Assert
var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
Assert.AreEqual(inputMessage, state.inputMessage);
}
[UnityTest]
public IEnumerator MainViewModelTest_SendMessageCommand()
{
// Arrange
var inputMessage = "Hello";
_viewModel.SetInputMessageCommand.Execute(inputMessage);
// Act
var task = Task.Run(() => _viewModel.SendMessageCommand.ExecuteAsync(null));
while (!task.IsCompleted) yield return null;
// Assert
var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
Assert.AreEqual(2, state.ChatMessages.Count);
Assert.That(state.ChatMessages[1].Content[0].Text, Is.Not.Null);
}
[UnityTest]
public IEnumerator MainViewModelTest_SendMessageCommand_WhenRunning()
{
// Arrange
var inputMessage = "Hello";
_viewModel.SetInputMessageCommand.Execute(inputMessage);
// Act
var task = Task.Run(() => _viewModel.SendMessageCommand.ExecuteAsync(null));
var task2 = Task.Run(() => _viewModel.SendMessageCommand.ExecuteAsync(null));
while (!task.IsCompleted || !task2.IsCompleted) yield return null;
// Assert
var state = _storeService.Store.GetState<AppState>(Actions.SliceName);
Assert.AreEqual(2, state.ChatMessages.Count);
Assert.That(state.ChatMessages[1].Content[0].Text, Is.Not.Null);
}
}
}
記述できたらTestRunnerから実行してみましょう。
全て緑色になれば成功です!
これにてViewModelの実装は終了です。
次からはアプリの実装に移っていきます。
3章 おわりに
MVVMの実装をやっていきました。
手続き的に実装するよりもはるかにコード量がおおく大変ですが、単位テストができるぶん安心して開発できるのではないかと思います。
4章ではついにチャットアプリが完成します。
がんばっていきましょう。
この章を通じて、MVVMとReduxの強力な組み合わせを見てきました。この知識を使って、皆さん自身のプロジェクトにどのように応用できるかを探ってみてください。何か質問や提案、この記事の間違いがあれば、ぜひシェアしてください!
では!