簡単なチャットボットを作ってみよう（フロントエンド編）

船井総研デジタルのtakizawaです。
今回は[こちらの記事](https://qiita.com/fsd-takizawa/items/b0aba71ee46549a05ad2)の続きとなります。前回はバックエンドの（仮）実装をおこないました。今回はフロントエンドの実装を行います。言語はTypeScriptでVue.js（Vue3）を利用します。

#  ストアモジュールの実装

早速実装していこうと思います。まずはチャット内容を保持するストアモジュールを実装します。
初めにインターフェースを次のように定義します。

```ts:chatMessage.ts
export interface MessageData {
  id: number
  message: string
  isBot: boolean
}

interface SendMessage {
  message: string
}

interface State {
  messageList: Map<number, MessageData>
  isLoading: boolean
}
```

* ```MessageData``` は一つの発言とそれに対する属性をまとめたものになります。発言は ```message``` フィールドに格納します。```isBot``` フィールドにはその発言がボットにより発信された場合 ```true``` が設定されます。

* ```SendMessage``` はバックエンドに連携するデータインターフェースです。

* ```State``` はストアの状態を管理するインターフェースです。 ```messageList``` フィールドは会話内容を保持します。また ```isLoading``` は処理中の場合に送信ボタンを非活性するために使用します。

続いてストアを実装します。

```ts:src/stores/chatMessage.ts
import { defineStore } from 'pinia'
...
export const useChatSotre = defineStore({
  id: 'chat',
  state: (): State => { // 管理する状態の定義と初期化
    return {
      messageList: new Map<number, MessageData>(),
      isLoading: false
    }
  },
  getters: {}, // 今回は使用しない
  actions: { // アクション
    // ユーザの入力したメッセージをmessageListに追加
    addUserMessage(question: string) {
      const setId = this.messageList.size + 1
      this.messageList.set(setId, {
        id: setId,
        message: question,
        isBot: false
      })
    },

    // ボットから回答を受けメッセージを追加
    async getBotReply(question: string) {
      // バックエンド連携
      const backendUrl = '<バックエンドAPIのURL>'
      const sendMessage: SendMessage = {
        message: question
      }

      await fetch(backendUrl, {
        method: 'POST',
        mode: 'cors',
        cache: 'no-cache',
        credentials: 'same-origin',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json'
        },
        body: JSON.stringify(sendMessage)
      })
        .then((response) => {
          response
            .json()
            .then((value) => {
              const replyData = value[0]
              // 回答構成
              const setId = this.messageList.size + 1
              this.messageList.set(setId, {
                id: setId,
                message: replyData.reply,
                isBot: true
              })
            })
            .catch((reason) => {
              console.error('Error at reply object construction.', reason)
            })
        })
        .catch((reason) => {
          if (reason instanceof Error) {
            console.error(reason.message, reason.stack)
            window.alert(reason.message)
          } else {
            console.error('Error occured.', reason)
            window.alert('エラー発生')
          }
        })
        .finally(() => {
          this.isLoading = false
        })
    }
  }
})

```

ストアライブラリはPiniaを使用します。Piniaを使用したストアの基本構造は次のようになります。
```ts
export const useXxxxStore = defineStore({
    id: 'xxxxx', // ストア名
    state: (): stateの型 => ({
        return {
            field: フィールドの初期値,
        }
    }),
    getters: {
        // データの加工処理
        getterName: (state): 加工データの型 => {
            // 処理
            return 加工データ
        },
        ...
    },
    actions: {
        // データの変更処理
        functionName(): void {
            // stateの変更処理
        }
    }
});
```

## fetch()
基本構造は次の通りです。設定しているパラメータについては[こちら](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Using_Fetch)を参照ください。
```ts
      await fetch(backendUrl, {
        method: 'POST',
        mode: 'cors',
        cache: 'no-cache',
        credentials: 'same-origin',
        headers: {
          'Content-Type': 'application/json',
          Accept: 'application/json'
        },
        body: JSON.stringify(送信オブジェクト)
      })
        .then((response) => { // レスポンス正常の場合
          response
            .json()
            .then((value) => {
                // Json形式でのレスポンスの取得が正常な場合の処理
            })
            .catch((reason) => {
              // エラー処理
            })
        })
        .catch((reason) => {
          // レスポンスエラーの場合
        })
        .finally(() => {
          // 最後に必ず実行する処理
        })
```

処理の流れは次の通りです。
1. ```fetch```関数でバックエンドにPOSTリクエストを送信します。
1. レスポンスが正常であれば結果をJSONとして取得しAPIからの返信をメッセージリストに追加。JSON変換が失敗した場合エラー処理をします。
1. レスポンスがエラーの場合はエラー処理を行います。
1. レスポンスの結果にかかわらず、最後に```isLoading```フラグを```false```に変更します。

# レイアウト
画面レイアウトのイメージを示します。
![スクリーンショット 2023-08-25 161218.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473569/3d627f6f-42e6-a1ef-3b2c-b0b273395ca4.png)
このうち下部の入力フォーム部分だけコンポーネントとして切り出して実装します。
![スクリーンショット 2023-08-25 161607.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3473569/a3afac37-e341-b632-1cbd-1c3bc5c241ad.png)

# Formコンポーネントの実装

## scriptブロック

まずはscript部分を実装を見てみます。
```vue:src/components/ChatForm.vue
<script setup lang="ts">
import { ref, computed, onUpdated } from 'vue'
import { useChatSotre } from '@/stores/chatMessage'

const chatStore = useChatSotre()

const sendMessage = ref('')
const isLoading = computed((): boolean => {
  return chatStore.isLoading
})

const onSendMessage = (): void => {
  chatStore.isLoading = true
  chatStore.addUserMessage(sendMessage.value)
  chatStore.getBotReply(sendMessage.value)
  sendMessage.value = ''
}

onUpdated((): void => {
  window.scrollTo({
    top: document.body.scrollHeight,
    behavior: 'smooth'
  })
})
</script>
```
まず使用するストアを```const chatStore = useChatSotre()```で取得します。
続いてリアクティブ変数```sendMessage, isLoading```を定義します。ストアのstateも状態が変化したときに再描画する必要があるためリアクティブ変数に入れなおしておく必要があります。
```onSendMessage```はFormをサブミットした際に呼び出す関数です。ストアの状態を変更し、最後にリアクティブ変数```sendMessage```を空文字としています。 __リアクティブ変数にアクセスする際は```value```プロパティに対して行います。__
最後に```onUpdated```ですが、これはライフサイクルフックの一つでDOMの再レンダリングが完了した時点で実行されます。ここではメッセージ表示部分を最下部までスクロールします。

## templateブロック

scriptブロックに続けてtemplateブロックを実装します。
```vue:src/components/ChatForm.vue
<template>
  <form v-on:submit.prevent="onSendMessage" class="send-form">
    <input type="text" id="sendMessage" v-model="sendMessage" required class="input-text" />
    <button type="submit" v-bind:disabled="isLoading">送信</button>
  </form>
</template>
```
```class```属性に指定しているのは後ほどstyleブロックで定義するスタイルです。Vue.js特有の属性は```v-model```と```v-bind```です。```v-model```は双方向バインディングで、ここではリアクティブ変数```sendMessage```をバインドしています。```v-bind```は単純なデータバインディングです。またここでは属性値がない属性```disabled```へリアクティブ変数```isLoading```をバインドしています。これは```isLoading = true```の場合にdisabled属性が指定された状態でレンダリングされます。

## styleブロック

```scoped```を付けることで定義したスタイルのスコープを同一モジュールにのみ制限できます。つけなければプロジェクト全体に有効です。

```vue:src/components/ChatForm.vue
<style scoped>
.input-text {
  flex: 1%;
  border: none;
  border-radius: 5px;
  padding: 10px;
  margin-right: 10px;
  margin-top: 5px;
  margin-bottom: 5px;
  font-size: 16px;
  height: 20px;
}
.send-form {
  display: flex;
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  padding: 10px;
  background-color: white;
  box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.3);
  z-index: 1;
}
</style>
```
# ビューの実装

## scriptブロック

メッセージの一覧を表示するためにストアから```messageList```をリアクティブ変数とします。
```vue:src/views/BotBox.vue
<script setup lang="ts">
import { computed } from 'vue'
import { useChatSotre, type MessageData } from '@/stores/chatMessage'
import ChatForm from '@/components/ChatForm.vue'

const chatStore = useChatSotre()
const messageList = computed((): Map<number, MessageData> => {
  return chatStore.messageList
})
</script>
```

## templateブロック

コードは次の通りです。
```vue:src/views/BotBox.vue
<template>
  <div class="container">
    <template v-for="[id, message] in messageList" v-bind:key="id">
      <div v-if="message.isBot" class="bot-message">{{ message.message }}</div>
      <div v-else class="user-message">{{ message.message }}</div>
    </template>
    <ChatForm />
  </div>
</template>
```
```v-for```でメッセージリストからループしながら```id```と```message```を取得し```key```属性に```id```を設定します。
このループtemplateの子要素として```<div>```でメッセージを表示します。その際に```v-if, v-else```でユーザーの発言かボットの応答かを条件分岐してスタイルを切り替えています。

## styleブロック

説明が必要なことはないのでコードだけ記載します。
```vue:src/views/BotBox.vue
<style scoped>
.user-message {
  max-width: 80%;
  width: auto;
  margin: 10px;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
  align-self: flex-end;
  background-color: #2d81d8;
  color: white;
}
.bot-message {
  max-width: 80%;
  width: auto;
  margin: 10px;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
  align-self: flex-start;
  background-color: #f2f2f2;
}
.container {
  flex: 1;
  padding: 20px;
  padding-bottom: 70px !important;
  display: flex;
  flex-direction: column;
}
</style>
```

# routerの実装

単一ページのアプリケーションのた```BotBox.vue```を```App.vue```に配置するだけでよいですがサンプルとしてrouterを使用します。詳しくは[公式ドキュメント](https://router.vuejs.org/)をご覧ください。
実装例だけ示します。
```ts:src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import BotBox from '@/views/BotBox.vue'

const routerSetting: RouteRecordRaw[] = [
  {
    path: '/',
    name: 'Top',
    component: BotBox
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: routerSetting
})

export default router
```
```vue:src/App.vue
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>

<template>
  <template class="main-box">
    <RouterView />
  </template>
</template>

<style scoped>
.main-box {
  display: flex;
  position: relative;
  left: 0;
  right: 0;
}
</style>
```
```App.vue```のstyleブロックは自動生成されたものから変更しています。

# 他スタイルの調整

自動生成された```main.css```をそのまま使用すると```BotBox.vue```のスタイルがうまく効かないため、スタイルを変更します。
```css:src/assets/main.css
@import './base.css';

#app {
  max-width: 1280px;
  font-weight: normal;
  align-items: center;
}

a,
.green {
  text-decoration: none;
  color: hsla(160, 100%, 37%, 1);
  transition: 0.4s;
}

@media (hover: hover) {
  a:hover {
    background-color: hsla(160, 100%, 37%, 0.2);
  }
}

@media (min-width: 100%) {
  body {
    display: flex;
    place-items: center;
  }

  #app {
    display: flex;
  }
}
```
# まとめ

今回はTypeScriptでVue.js（Vue3）を利用してフロントエンド側の実装を見ました。
次回はクロスオリジンリソース共有 (CORS) エラーの解消を行い、フロントエンドとバックエンドを連携させます。

続編の記事を投稿しました！
[簡単なチャットボットを作ってみよう（CORSエラー対応）](https://qiita.com/fsd-takizawa/items/6ba20a788e48fc74b36c)