Vercel AI SDK 2.0/3.0とNext.jsを使ったチャットアプリケーション解説

# はじめに

この記事では、[**Vercel AI SDK**](https://sdk.vercel.ai/docs)を使って私が最近作ったチャットアプリケーションについて説明したいと思います。

1つ目のアプリケーションは[こちら](https://mulai.vercel.app/)です。少し使ってみてください。様々な生成AIから選択し、あるいは同時に質問をし、モデルによっては**画像の解釈**をしたり、**画像を生成**したりできるようになっています。

https://mulai.vercel.app/

アプリケーションはもう一つあります。似たような見栄えですが、少し作りが違い、リサイズなどの機能は未実装な一方で、**天気情報**や**画像生成**などの機能を**関数呼び出し**(OpenAI互換のFunction Calling)の仕組みで対応しています。また、AI SDK 3.0から対応したRSC(React Server Components)の仕組みを使っています。

https://mulai3.vercel.app/

それぞれソースコードも公開していますので、必要に応じてこれらも眺めながら記事を読んでいただければと思います。Vercelの[Deploy Button](https://vercel.com/docs/deployments/deploy-button)を設置してあるので、幾つかの環境変数を設定すればあなたのVercel環境にデプロイできます。
- [Mulai ソースコード](https://github.com/twxxk/mulai)
- [Mulai3 ソースコード](https://github.com/twxxk/mulai3)

# Mulaiについて

Mulaiは、ChatGPTやGeminiなどの生成AIを使う中での私の不満から作ったアプリケーションです。

世の中のさまざまな生成AIの中には、**回答の質は高いけれど速度が遅い**AIと、**速度は速いけれど回答の質が低いことも多い**AIがあると思います。また、**使用するタイミングによってエラーになってしまう**こともあると思います。これは体験を損ねるので、どうにかしたいと思ってこのアプリケーションを作りました。

その対策としてMulaiでは、複数の異なるモデル、異なるベンダーのAIに同時に同じ質問をできる仕組みにしています。作り終わってから、[Vercel AI SDKサイト内](https://sdk.vercel.ai/)でほぼ同じことができることに気づき。しかも気づく前にVercel社の人達にこのアプリを見せていてかなり恥ずかしいのですが、当サイトのソースコードは今のところ公開されていないと思うので、Mulai/Mulai3ではソースコードを公開しつつこの記事などで中身の説明もして、他の方の参考にしていただき供養したいと思っています。公式サイトでは使えないGoogle社やAWS Bedrockなどのモデルも選べますので、少しばかり良い面もある、と言うことにさせてください。

# Mulai3について

Mulaiを作った時、AI SDKはバージョン2で、後で再度説明しますが、AIとメッセージをやり取りするためにはアプリケーション側でバックエンドAPIを用意し、フロントエンドからはそのAPIを経由して通信する必要がありました。

その後、[AI SDK 3.0](https://vercel.com/blog/ai-sdk-3-generative-ui)がリリースされました。AI SDK 3.0からはReactのサーバーコンポーネント (RSC) の仕組みに対応しており、別途APIのエンドポイントを用意しなくても、AI呼び出しができるようになっています。そのおかげもあって、特に生成AIによる関数呼び出しがシームレスに実施できるようになりました。

その一方で、クライアントコンポーネントが必要となるイベントハンドリングなどの記述はやや煩雑になるため、Mulaiで実装していた幾つかの機能は未実装となっています。

# 初めてのAI SDKの呼び出し

一番単純な例として、まず[公式のGetting Started](https://sdk.vercel.ai/docs/getting-started)に従って実装するのが良いと思います。

バックエンド側は、下記のような記述でストリーミングしたレスポンスを取得できます。文字列で取得したければ`stream: false`を指定すればOKです。

```node.js
  // const messages = [{role: 'user', content: 'こんにちは'}];        
  const response = await openai.chat.completions.create({
    model: 'gpt-3.5-turbo',
    stream: true,
    messages,
  });
 
  const stream = OpenAIStream(response);
  return new StreamingTextResponse(stream);
```

各AIベンダーのAPIはどれもわかりやすいので、これだけ見ると、VercelのSDKを通すメリットがあまりないように感じるかも知れませんが、今回Mulaiで実現しているような複数モデルの切り替えを非常に容易にできることは一つのメリットです。

API呼び出しをするフロントエンド側ですが、下記の形となります（動作するコードの全体は、バックエンドフロントエンドともGetting Startedにある記述を使ってください）。

```react
  const { messages, input, handleInputChange, handleSubmit } = useChat();
  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>{m.role}:{m.content}</div>
      ))} 
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} />
      </form>
    </div>
  );
```

つまり、AI SDKが提供する`useChat()`という関数の戻り値を使うだけで、自動的に入力値の整形と送信およびストリーミングしたレスポンスの出力を行ってくれる、ということになります。


# 初めてのAI SDK RSCの呼び出し

AI SDK 3.0のRSCのドキュメントは[Generative UI](https://sdk.vercel.ai/docs/concepts/ai-rsc)と題打ってコード付きで説明がありますし、SDKの公式ツリーの中にもサンプルアプリケーション[next-ai-rsc](https://github.com/vercel/ai/tree/main/examples/next-ai-rsc)があります。

しかし、これには実際のアプリケーションに適用しようとすると使いづらい部分が幾つかあります。中でも、[モデル名が`app/action`に埋め込まれている](https://github.com/vercel/ai/blob/500b1fd613111a654483c9ecf95562f371d92611/examples/next-ai-rsc/app/action.tsx#L116C1-L117C1)ので、モデルが固定の場合は良いのですが、今回のアプリケーションのように動的にモデルを切り替えたい場合は作りを少し変える必要があります。

また、AI SDKとのやり取りに利用する`AIState`と`UIState`の型が、下記のようにメッセージの配列になっており、それ以外の要素を付け足すのが難しくなっているところもユースケースによって使い勝手が悪くなってしまうところかと思います。

```action.tsx
const initialAIState: {
  role: 'user' | 'assistant' | 'system' | 'function';
  content: string;
  id?: string;
  name?: string;
}[] = [];


const initialUIState: {
  id: number;
  display: React.ReactNode;
}[] = [];
```

ここで、Mulai3の初期ソースでの対応内容を紹介します。`submitUserMessage`関数でモデルを指定している部分は、`AIState`から取得するようにして、`AIState`の中にモデル値を指定できる項目を増やしています。これに合わせて、他の箇所も調整しています。

```action.tsx
  const ui:any = render({
    model: aiState.get().modelValue,
    provider: openai,
    messages: [
      { role: 'system', content: 'You are a helpful assistant' },
      { role: 'user', content: userInput }
    ],
```

```action.tsx
const initialAIState:AIState = {
  messages:[], 
  modelValue:'gpt-3.5-turbo',
};
```

実際のソースコードはこちらをご参照ください。最新のMulai3のソースコードよりもシンプルで理解は早いかと思います。

https://github.com/twxxk/mulai3/tree/9b484d3a94d2c3d0e2daf8459492a937edb8f6e8

# Mulaiで対応しているProvider（モデル提供者）

Vercel AI SDKでは、現在下記のProvider（モデル提供者）に対応しています。プロバイダー同士で提供しているモデルに重複もありますので一部を除外し、太字にした部分がMulaiで対応しているプロバイダーです。GroqとMistralはなぜか[参照した公式ドキュメント](https://sdk.vercel.ai/docs/guides)に記載がありませんでしたが、SDKは対応しており、他のドキュメントには明記されています。

- **OpenAI**
- Azure OpenAI
- **AWS Bedrock**
- **Anthropic**
- **Cohere**
- **Fireworks.ai**
- **Google**
- **Hugging Face**
- Inkeep
- LangChain
- **Perplexity**
- Replicate
- **Groq**
- **Mistral**

AI SDKを利用していてもプロバイダーによって呼び出し部分は多少書き換える必要がありますが、プロバイダーごとの独自部分については次のようなインターフェースを持つ関数にラップしています。

```route.ts
interface ChatStreamFunction {
    ({model, messages}: {model: ChatModel, messages: Message[]}): Promise<ReadableStream>;
}

// 略...

function chatStreamFactory(model: ChatModel):ChatStreamFunction {
    // key is actually ModelProvider
    const providerMap:{[key:string]:ChatStreamFunction} = {
        'openai': openaiChatStream,
        'openai-image': openaiImageStream,
        'google': googleChatStream,
        'fireworksai': fireworksChatStream,
        'fireworksai-image': fireworksImageStream,
        'huggingface': huggingFaceStream,
        'huggingface-image': huggingFaceImageStream,
        'groq': groqChatStream,
        'cohere': cohereChatStream,
        'aws': awsAnthropicChatStream,
        'mistral': mistralChatStream,
        'perplexity': perplexityChatStream,
        // 'langchain': langchainChatStream,
        'anthropic': anthropicChatStream,
    }
    const stream = providerMap[model.provider as string]
    if (!stream) {
        console.error('unexpected model', model)
        throw new Error('unexpected request')
    }
    return stream
}
```

クライアント側が指定したモデルに応じて、処理するインターフェースを分けて処理を行う部分は非常にシンプルに記述できますし、プロバイダーが増えた場合の作業もわかりやすくなったかと思います。

```route.ts
        const responseStreamGenerator = chatStreamFactory(modelData)
        const stream = await responseStreamGenerator({model:modelData, messages: m})
        return new StreamingTextResponse(stream)
```

詳しくは[`api/chat/route.ts`](https://github.com/twxxk/mulai/blob/main/app/api/chat/route.ts)をご覧ください。

# Mulai3で対応しているProvider（モデル提供者）

Mulai3で使用するRSCの`render`関数は、現在OpenAI互換のプロバイダーのみ対応している（[ドキュメント](https://sdk.vercel.ai/docs/api-reference/generative-ui/render)）ため、下記プロバイダーが対応となります。Googleなどが未サポートなのが残ですが、今後SDKのバージョンアップとともに増えていくことを期待します。

- **OpenAI**
- **Fireworks.ai**
- **Perplexity**
- **Groq**
- **Mistral**

ちなみに、Mistralについて、AI SDKでは[Mistral向けのインターフェースも提供](https://sdk.vercel.ai/docs/api-reference/providers/mistral-stream)していますが、OpenAI互換インターフェースでも動作します。

```ai-action.tsx
const mistral = new OpenAI({
  apiKey: process.env.MISTRAL_API_KEY,
  baseURL: 'https://api.mistral.ai/v1',
})
```

# Mulai、Mulai3で対応しているモデル

上記プロバイダーが提供しているチャットモデルについては基本的にどれでも動作します。重複しているモデルや、料金の割に品質が多いモデルなどを省き、現在は下記の各モデルに対応しています。特にHugging Face上のモデルについては一定品質のみのモデルとしていますが、上記プロバイダーの切り分けの仕組みと合わせて、良いモデルがあれば簡単に追加できる仕組みになっています。

```ai-model.ts
const allModels0:ChatModel0[] = [
    {label: 'GPT-3.5', provider: 'openai', modelValue: 'gpt-3.5-turbo', sdkModelValue: 'gpt-3.5-turbo', qualityScore: 118/256*100, japaneseScore: 67, doesSupportTool: true,},
    // in $0.03/1K tokens, out $0.06/1K tokens
    {label: 'GPT-4', provider: 'openai', modelValue: 'gpt-4', sdkModelValue: 'gpt-4', qualityScore: 254/256*100, japaneseScore: 76, doesSupportTool: true,},
    // in $0.01/1K tokens, out $0.03/1K tokens 
    {label: 'GPT-4 Turbo', provider: 'openai', modelValue: 'gpt-4-turbo-preview', sdkModelValue: 'gpt-4-turbo-preview', qualityScore: 253/256*100, japaneseScore: 77, doesSupportTool: true,},
    // 1024x1024 in high costs 765 tokens
    // GPT-4 Vision responds only tens of chars if no max_tokens is given.
    {label: 'GPT-4 Vision', provider: 'openai', modelValue: 'gpt-4-vision-preview', sdkModelValue: 'gpt-4-vision-preview', qualityScore: 118/256*100, japaneseScore: 67, maxTokens: 4096, doesSupportTool: true, doesAcceptImageUrl: true, }, 

    // in $0.25 / out $1.25 /1M tokens
    {label: 'Anthropic Claude 3 Haiku', provider: 'anthropic', modelValue: 'claude-3-haiku-20240307', sdkModelValue: 'claude-3-haiku-20240307', qualityScore: 119/256*100, japaneseScore:63, maxTokens: 4096, doesAcceptImageUrl: true, },
    // in $3.00 / out $15.00 /1M tokens
    {label: 'Anthropic Claude 3 Sonnet', provider: 'anthropic', modelValue: 'claude-3-sonnet-20240229', sdkModelValue: 'claude-3-sonnet-20240229', qualityScore: 254/256*100, japaneseScore:64, maxTokens: 4096, doesAcceptImageUrl: true, },
    // in $15.00 / out $75.00 /1M tokens
    {label: 'Anthropic Claude 3 Opus', provider: 'anthropic', modelValue: 'claude-3-opus-20240229', sdkModelValue: 'claude-3-opus-20240229', qualityScore: 255/256*100, japaneseScore:64, maxTokens: 4096, doesAcceptImageUrl: true, },

    // free (up to 60queries/min) 
    {label: 'Google Gemini 1.0 Pro', provider: 'google', modelValue: 'gemini-1.0-pro', sdkModelValue: 'gemini-pro', qualityScore: 122/256*100, japaneseScore: 64},
    // free (up to 60queries/min) 
    {label: 'Google Gemini 1.0 Pro Latest', provider: 'google', modelValue: 'gemini-1.0-pro-latest', sdkModelValue: 'gemini-1.0-pro-latest', qualityScore: 218/256*100, japaneseScore: 64},
    // cannot call from api. You can check available models from colab. https://ai.google.dev/tutorials/python_quickstart
    // {label: 'Google Gemini 1.5 Pro', provider: 'google', modelValue: 'gemini-1.5-pro-latest', sdkModelValue: 'gemini-1.5-pro-latest', qualityScore: 219/256*100, japaneseScore: 65},
    {label: 'Google Gemini Pro Vision', provider: 'google', modelValue: 'gemini-pro-vision', sdkModelValue: 'gemini-pro-vision', qualityScore: 218/256*100, japaneseScore: 64, doesAcceptImageUrl: true, },

    // no longer necessary. Claude 3 is cheeper and better
    // in $0.0008/1k tokens, out $0.0024/1k tokens
    {label: 'Anthropic Claude Instant', provider: 'aws', modelValue: 'anthropic.claude-instant-v1', sdkModelValue: 'anthropic.claude-instant-v1', qualityScore: 150/256*100, japaneseScore:64}, // fast
    // in $0.008/1k tokens, out $0.024/1k tokens
    {label: 'Anthropic Claude 2.1', provider: 'aws', modelValue: 'anthropic.claude-v2', sdkModelValue: 'anthropic.claude-v2:1', qualityScore: 120/256*100, japaneseScore:67},

    // in 2.5€/M, out 7.5€/M
    {label: 'Mistral Medium', provider: 'mistral', modelValue: 'mistral-medium', sdkModelValue: 'mistral-medium', qualityScore: 152/256*100, japaneseScore:50},

    // free
    {label: 'Japanese StableLM Instruct Beta 70B', provider: 'fireworksai', modelValue: 'japanese-stablelm-instruct-beta-70b', sdkModelValue: 'accounts/stability/models/japanese-stablelm-instruct-beta-70b', qualityScore: 40, japaneseScore:37},

    {label: 'Qwen 72B Chat', provider: 'fireworksai', modelValue: 'qwen-72b-chat', sdkModelValue: 'accounts/fireworks/models/qwen-72b-chat', qualityScore: 147/256*100, japaneseScore:20},
    {label: 'Qwen 14B Chat', provider: 'fireworksai', modelValue: 'qwen-14b-chat', sdkModelValue: 'accounts/fireworks/models/qwen-14b-chat', qualityScore: 35/256*100, japaneseScore:10},

    // free. OSS based
    {label: 'FireLLaVA 13B', provider: 'fireworksai', modelValue: 'firellava-13b', sdkModelValue: 'accounts/fireworks/models/firellava-13b', qualityScore: 33, japaneseScore:15, doesAcceptImageUrl: true, },

    {label: 'Perplexity Sonar Small', provider: 'perplexity', modelValue: 'sonar-small-chat', sdkModelValue: 'sonar-small-chat', qualityScore: 59, japaneseScore:12},
    // {label: 'Perplexity Sonar Small Online', provider: 'perplexity', modelValue: 'sonar-small-online', sdkModelValue: 'sonar-small-chat', qualityScore: 58, japaneseScore:11},
    {label: 'Perplexity Sonar Medium', provider: 'perplexity', modelValue: 'sonar-medium-chat', sdkModelValue: 'sonar-medium-chat', qualityScore: 61, japaneseScore:14},
    // {label: 'Perplexity Sonar Medium Online', provider: 'perplexity', modelValue: 'sonar-medium-online', sdkModelValue: 'sonar-medium-chat', qualityScore: 60, japaneseScore:13},

    {label: 'Gemma 7B Instruct', provider: 'huggingface', modelValue: 'gemma-7b-it', sdkModelValue: 'google/gemma-7b-it', qualityScore: 40, japaneseScore:10},
    {label: 'Gemma 7B', provider: 'huggingface', modelValue: 'gemma-7b', sdkModelValue: 'google/gemma-7b', qualityScore: 40-1, japaneseScore:10},
    {label: 'Gemma 2B Instruct', provider: 'huggingface', modelValue: 'gemma-2b-it', sdkModelValue: 'google/gemma-2b-it', qualityScore: 40/7*2, japaneseScore:0},
    {label: 'Gemma 2B', provider: 'huggingface', modelValue: 'gemma-2b', sdkModelValue: 'google/gemma-2b', qualityScore: 40/7*2-1, japaneseScore:0},

    {label: 'Groq Mixtral 8x7b', provider: 'groq', modelValue: 'groq-Mixtral-8x7b-Instruct-v0.1', sdkModelValue: 'mixtral-8x7b-32768', qualityScore: 152/256*100+1, japaneseScore:5},
    {label: 'Groq Llama 2 70B Chat', provider: 'groq', modelValue: 'groq-LLaMA2-70b-chat', sdkModelValue: 'llama2-70b-4096', qualityScore: 82/256*100+2, japaneseScore:5},
    {label: 'Groq Gemma 7B Instruct', provider: 'groq', modelValue: 'groq-gemma-7b-it', sdkModelValue: 'gemma-7b-it', qualityScore: 40/256*100+2, japaneseScore:10},

    {label: 'Mistral Small', provider: 'mistral', modelValue: 'mistral-small', sdkModelValue: 'mistral-small', qualityScore: 40, japaneseScore:5},
    // in 0.14€/M, out 0.42€/M
    {label: 'Mistral Tiny', provider: 'mistral', modelValue: 'mistral-tiny', sdkModelValue: 'mistral-tiny', qualityScore: 40, japaneseScore:10},
    {label: 'Mixtral 8x7b MoE (Hugging Face)', provider: 'fireworksai', modelValue: 'mixtral-8x7b-instruct-hf', sdkModelValue: 'accounts/fireworks/models/mixtral-8x7b-instruct-hf', qualityScore: 120/256*100, japaneseScore:5},
    // in:$0.4/M out:$1.6/M
    {label: 'Mixtral MoE 8x7B Instruct', provider: 'fireworksai', modelValue: 'mixtral-8x7b-instruct', sdkModelValue: 'accounts/fireworks/models/mixtral-8x7b-instruct', qualityScore: 120/256*100, japaneseScore:5},
    {label: 'Mistral 7B Instruct', provider: 'fireworksai', modelValue: 'mistral-7b-instruct-4k', sdkModelValue: 'accounts/fireworks/models/mistral-7b-instruct-4k', qualityScore: 152/256*100, japaneseScore:0},
    {label: 'Cohere Command Nightly', provider: 'cohere', modelValue: 'cohere-command-nightly', sdkModelValue: 'command-nightly', qualityScore: 40, japaneseScore:0},
    {label: 'Cohere Command Light Nightly', provider: 'cohere', modelValue: 'cohere-command-light-nightly', sdkModelValue: 'command-light-nightly', qualityScore: 40, japaneseScore:0},

    // free
    {label: 'Llama 2 70B Chat', provider: 'fireworksai', modelValue: 'llama-v2-70b-chat', sdkModelValue: 'accounts/fireworks/models/llama-v2-70b-chat', qualityScore: 82/256*100+1, japaneseScore:5},
    {label: 'Llama 2 70B Code Llama instruct', provider: 'fireworksai', modelValue: 'llama-v2-70b-code-instruct', sdkModelValue: 'accounts/fireworks/models/llama-v2-70b-code-instruct', qualityScore: 82/256*100, japaneseScore:5},
    // free
    {label: 'Llama 2 13B Chat', provider: 'fireworksai', modelValue: 'llama-v2-13b-chat', sdkModelValue: 'accounts/fireworks/models/llama-v2-13b-chat', qualityScore: 45/256*100, japaneseScore:5},
    {label: 'Llama 2 7B Chat', provider: 'fireworksai', modelValue: 'llama-v2-7b-chat', sdkModelValue: 'accounts/fireworks/models/llama-v2-7b-chat', qualityScore: 27/256*100, japaneseScore:5},

    {label: 'Capybara 34B', provider: 'fireworksai', modelValue: 'yi-34b-200k-capybara', sdkModelValue: 'accounts/fireworks/models/yi-34b-200k-capybara', qualityScore: 111/256*100, japaneseScore:5},
    {label: 'Open-Assistant SFT-4 12B', provider: 'huggingface', modelValue: 'oasst-sft-4-pythia-12b-epoch-3.5', sdkModelValue: 'OpenAssistant/oasst-sft-4-pythia-12b-epoch-3.5', qualityScore: 40, japaneseScore:0},
    // free. very low quality
    // {label: 'Japanese Stable LM Instruct Gamma 7B', provider: 'fireworks.ai', modelValue: 'japanese-stablelm-instruct-gamma-7b', sdkModelValue: 'accounts/stability/models/japanese-stablelm-instruct-gamma-7b', qualityScore: 40, japaneseScore:20}, // only work with 1 or 2 messages
    {label: 'StableLM Zephyr 3B', provider: 'fireworksai', modelValue: 'stablelm-zephyr-3b', sdkModelValue: 'accounts/stability/models/stablelm-zephyr-3b', qualityScore: 40, japaneseScore:0},

    // Vercel AI SDK for AWS supports Claude body format (prompt) but not titan (inputText)
    // {label: 'Titan Text G1 - Express', provider: 'aws', modelValue: 'amazon.titan-text-express-v1', sdkModelValue: 'amazon.titan-text-express-v1', recommendScore:20},  

    // Image generation - Works fine, but UI should be updated
    {label: 'DALL·E 2', provider: 'openai-image', modelValue: 'dall-e-2', sdkModelValue: 'dall-e-2', qualityScore: 40, japaneseScore:10},
    {label: 'DALL·E 3', provider: 'openai-image', modelValue: 'dall-e-3', sdkModelValue: 'dall-e-3', qualityScore: 40, japaneseScore:40},
    {label: 'Stable Diffusion 2', provider: 'huggingface-image', modelValue: 'stable-diffusion-2', sdkModelValue: 'stabilityai/stable-diffusion-2', qualityScore: 40, japaneseScore:10},
] as const
```

`qualityScore`と`japaneseScore`はそれぞれ<a href="https://chat.lmsys.org/?arena">英語</a>および<a href="https://wandb.ai/wandb-japan/llm-leaderboard/reports/Nejumi-LLM-Neo--Vmlldzo2MTkyMTU0">日本語のモデル評価情報</a>をもとに、一部不明な物などは手作業で設定しています。これらの値とクライアント側の言語設定に基づき、モデル切り替え時のスタイルを変更しています（`option`タグにスタイル適用ができないSafariなどではすべて同じに見えます）。

画像生成や関数呼び出しについては、SDK側のインターフェースの違いもあり、一部インターフェースのみ対応としています。

```ai-model.ts
export type ChatModel = {
    label: ModelLabel, // for human
    provider: ModelProvider,
    modelValue: ModelValue, // for url parameter and mulai internal value
    sdkModelValue: SdkModelValue, // the value to be passed to AI sdk
    qualityScore: number, // 0..100 (1000..1256) https://chat.lmsys.org/?arena as of 2024-02-23
    japaneseScore: number, // 0..100 https://wandb.ai/wandb-japan/llm-leaderboard/reports/Nejumi-LLM-Neo--Vmlldzo2MTkyMTU0 as of 2024-02-23
    maxTokens?: number, // Only if it should be passed as a parameter
    doesToolSupport?: boolean, // If the model supports tools and function calls
}
```

----
今日の記事はいったん以上とします。次の記事では中身を説明していきたいと思います。

こちらのサンプルアプリケーションに関するその他記事は、タグ`Mulai`をご利用ください。

https://qiita.com/tags/mulai