3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-08-29

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

ストアモジュールの実装

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

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 は処理中の場合に送信ボタンを非活性するために使用します。

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

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を使用したストアの基本構造は次のようになります。

export const useXxxxStore = defineStore({
    id: 'xxxxx', // ストア名
    state: (): stateの型 => ({
        return {
            field: フィールドの初期値,
        }
    }),
    getters: {
        // データの加工処理
        getterName: (state): 加工データの型 => {
            // 処理
            return 加工データ
        },
        ...
    },
    actions: {
        // データの変更処理
        functionName(): void {
            // stateの変更処理
        }
    }
});

fetch()

基本構造は次の通りです。設定しているパラメータについてはこちらを参照ください。

      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リクエストを送信します。
  2. レスポンスが正常であれば結果をJSONとして取得しAPIからの返信をメッセージリストに追加。JSON変換が失敗した場合エラー処理をします。
  3. レスポンスがエラーの場合はエラー処理を行います。
  4. レスポンスの結果にかかわらず、最後にisLoadingフラグをfalseに変更します。

レイアウト

画面レイアウトのイメージを示します。
スクリーンショット 2023-08-25 161218.png
このうち下部の入力フォーム部分だけコンポーネントとして切り出して実装します。
スクリーンショット 2023-08-25 161607.png

Formコンポーネントの実装

scriptブロック

まずはscript部分を実装を見てみます。

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ブロックを実装します。

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-modelv-bindです。v-modelは双方向バインディングで、ここではリアクティブ変数sendMessageをバインドしています。v-bindは単純なデータバインディングです。またここでは属性値がない属性disabledへリアクティブ変数isLoadingをバインドしています。これはisLoading = trueの場合にdisabled属性が指定された状態でレンダリングされます。

styleブロック

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

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をリアクティブ変数とします。

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ブロック

コードは次の通りです。

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でメッセージリストからループしながらidmessageを取得しkey属性にidを設定します。
このループtemplateの子要素として<div>でメッセージを表示します。その際にv-if, v-elseでユーザーの発言かボットの応答かを条件分岐してスタイルを切り替えています。

styleブロック

説明が必要なことはないのでコードだけ記載します。

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.vueApp.vueに配置するだけでよいですがサンプルとしてrouterを使用します。詳しくは公式ドキュメントをご覧ください。
実装例だけ示します。

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
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のスタイルがうまく効かないため、スタイルを変更します。

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エラー対応)

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?