Vue2 × Vuetifyでチャットアプリつくーる（Powered by ChatGPT）

# はじめに
Vue2とVuetifyでチャットアプリを作りましょう

https://twitter.com/sotongshi/status/1648631672102092801


# 開発環境
- Windows 11 PC
- Node.js 18.15.0
- Vue CLI 5.0.8
- Vue 2.7.14
- Vuetify 2.6.15
- Azure OpenAI Service

# 実装

1．Node.jsのインストール

https://nodejs.org/ja

2．Node.js command promptを開き、Vueのインストール

~~~
npm install -g @vue/cli
~~~

3．プロジェクトを作成（プロジェクト名は小文字）

~~~
vue create chatapp
~~~

4．Vue2を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/64713548-85d3-e688-c2ef-bf1e6d9ff3a4.png)

5．一旦実行

~~~
cd chatapp
npm run serve
~~~

6．http://localhost:8080/にアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/8bf2de50-b56f-e7fe-f156-89e821352444.png)

7．Vuetifyをインストール

~~~
vue add vuetify
~~~

8．Vuetify 2 - Vue CLI (recommended) を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/033b182b-3ba9-3155-d5b0-ab9589a34f31.png)


9．一旦実行

~~~
npm run serve
~~~

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/5e870867-7f94-d170-16cc-ec8d4af07ac4.png)

10．vue-clipboard2、clipboard、markdown-it、axiosのインストール

~~~
npm install vue-clipboard2 clipboard markdown-it axios
~~~

11．App.vueを編集

~~~App.vue
<template>
  <v-app>
    <v-row no-gutters>
      <v-container>
        <v-row justify="center">
          <v-col cols="12" sm="10" md="8">
            <v-card class="mx-auto my-6" max-width="800">
              <v-card-text ref="conversation" style="
                  min-height: 400px;
                  max-height: 450px;
                  overflow-y: scroll;
                  padding-bottom: 4;
                ">
                <v-list>
                  <v-list-item v-for="(message, index) in messages" :key="index">
                    <v-list-item-avatar>
                      <v-icon v-if="message.isUser">mdi-account-circle</v-icon>
                      <v-icon v-else>mdi-robot</v-icon>
                    </v-list-item-avatar>
                    <v-list-item-content>
                      <v-card class="my-1" :class="{
                        'blue lighten-5': message.isUser,
                        'grey lighten-4': !message.isUser,
                      }">
                        <v-card-text>
                          <template v-for="(msg, idx) in parseText(message.text)">
                            <v-card-text v-if="!msg.isCode" :key="'msg' + idx" v-html="md.render(msg.text)"></v-card-text>
                            <v-card-text v-else :key="'code' + idx" class="code">
                              <v-card-text style="position: relative; padding-right: 4px">
                                <pre style="
                                    white-space: pre-wrap;
                                    border-radius: 4px;
                                    overflow: auto;
                                  ">{{ msg.text }}</pre>
                                <v-btn class="mt-2" color="primary" style="position: absolute; top: 0; right: 0"
                                  @click="copyToClipboard(msg.text)">Copy</v-btn>
                              </v-card-text>
                            </v-card-text>
                          </template>
                        </v-card-text>
                      </v-card>
                    </v-list-item-content>
                  </v-list-item>
                </v-list>
              </v-card-text>
              <v-divider></v-divider>
              <v-select v-model="selectedDeployment" :items="deployments" item-value="name" item-text="displayName"
                label="Deployment" hide-details solo clearable></v-select>
              <v-textarea v-model="inputText" :disabled="sending" label="Type a message" solo class="input-textarea"
                style="width: 100%; height: 100px" @keydown.enter.prevent="sendWithLineBreak"></v-textarea>
              <v-row justify="end" no-gutters>
                <v-col cols="auto">
                  <v-btn @click="sendMessage" :loading="sending" color="primary" icon><v-icon>mdi-send</v-icon></v-btn>
                </v-col>
              </v-row>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
    </v-row>
  </v-app>
</template>

<script>
import Vue from "vue";
import VueClipboard from "vue-clipboard2";
import ClipboardJS from "clipboard";
import MarkdownIt from "markdown-it";
import axios from "axios";

Vue.use(VueClipboard);

export default {
  mounted() {
    this.clipboard = new ClipboardJS(".copy-to-clipboard");
    this.clipboard.on("success", this.onClipboardSuccess);
  },
  data() {
    return {
      json_data: {
        messages: [
          { role: "system", content: "あなたはプロのAIアシスタントです。" },
        ],
      },
      messages: [],
      inputText: "",
      sending: false,
      md: new MarkdownIt(),
      selectedDeployment: "VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT35",
      deployments: [
        {
          name: "VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT35",
          displayName: "gpt-3.5-turbo",
        },
        { name: "VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT4", displayName: "gpt-4" },
        {
          name: "VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT432K",
          displayName: "gpt-4-32k",
        },
      ],
      drawer: true,
      selectedMessageId: null,
      selectedMessageTitle: "",
      conversationContainers: [],
    };
  },
  created() {
    this.messages.push({
      text: "こんにちは。なにかお手伝いできることはございますか？",
      isUser: false,
    });
  },
  methods: {
    copyToClipboard(code) {
      this.$copyText(code)
        .then(() => {
          this.$toast.success("Code copied to clipboard!");
        })
        .catch(() => {
          this.$toast.error("Failed to copy code!");
        });
    },
    parseText(text) {
      const codeRegex = /```([\w]+)?\n?([\s\S]+?)\n?```/g;
      const codeBlocks = [];
      let match;
      let lastIndex = 0;
      while ((match = codeRegex.exec(text)) !== null) {
        const [block, lang, code] = match;
        if (match.index > lastIndex) {
          const prevText = text.substring(lastIndex, match.index);
          codeBlocks.push({ isCode: false, text: prevText });
        }
        codeBlocks.push({ isCode: true, text: code.trim(), lang });
        lastIndex = match.index + block.length;
      }
      const lastText = text.substring(lastIndex);
      codeBlocks.push({ isCode: false, text: lastText });
      return codeBlocks;
    },
    sendWithLineBreak() {
      this.inputText += "\n";
    },
    sendMessage() {
      if (!this.inputText) {
        return;
      }
      this.sending = true;
      const messages = [{ text: this.inputText, isUser: true }];
      this.messages.push(...messages);
      const url = `${process.env.VUE_APP_OPENAI_BASE_URL}/openai/deployments/${process.env[this.selectedDeployment]
        }/chat/completions?api-version=${process.env.VUE_APP_OPENAI_API_VERSION}`;
      const headers = {
        headers: {
          "Content-Type": "application/json",
          "api-key": process.env.VUE_APP_OPENAI_API_KEY,
        },
      };
      this.json_data.messages.push({ role: "user", content: this.inputText });
      this.inputText = "";
      axios
        .post(url, this.json_data, headers)
        .then((response) => {
          console.log(response.data);
          this.messages.push({
            text: response.data.choices[0].message.content,
            isUser: false,
          });
          this.json_data.messages.push({
            role: "assistant",
            content: response.data.choices[0].message.content,
          });
          this.sending = false;
          this.$refs.conversation.scrollTop =
            this.$refs.conversation.scrollHeight;
          this.$nextTick(() => {
            this.$refs.conversation.scrollTop =
              this.$refs.conversation.scrollHeight;
          });
        })
        .catch((error) => {
          if (error.response.status === 429) {
            this.messages.push({
              text: "ただいま、大変混み合っております。しばらく待ってから再度お試しください。",
              isUser: false,
            });
            this.sending = false;
            this.$nextTick(() => {
              this.$refs.conversation.scrollTop =
                this.$refs.conversation.scrollHeight;
            });
          } else {
            this.sending = false;
            console.log(error);
          }
        });
      this.$nextTick(() => {
        this.$refs.conversation.scrollTop =
          this.$refs.conversation.scrollHeight;
      });
    },
  },
};
</script>

<style>
.code {
  background-color: #f0f0f0;
  padding: 10px;
  margin-bottom: 20px;
  font-size: 14px;
  line-height: 1.4;
  border-radius: 4px;
  overflow: auto;
}
</style>

<style scoped>
.input-textarea textarea {
  height: auto !important;
  max-height: 150px;
  overflow-y: auto;
}
</style>
~~~

12．main.jsを編集

~~~main.js
import Vue from 'vue'
import App from './App.vue'
import Vuetify from 'vuetify';
import vuetify from './plugins/vuetify'
import 'vuetify/dist/vuetify.min.css'

Vue.use(Vuetify)
Vue.config.productionTip = false

new Vue({
  vuetify,
  render: h => h(App)
}).$mount('#app')
~~~

13．.envを作成（VUE_APP_OPENAI_BASE_URLとVUE_APP_OPENAI_API_KEYはAzureポータルからコピペ）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63863/4bf1e42f-c12e-a5d6-9280-ebcd10e76b25.png)

~~~.env
VUE_APP_OPENAI_BASE_URL=https://xxxx.openai.azure.com/
VUE_APP_OPENAI_API_KEY=xxxx
VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT432K=gpt-4-32k
VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT4=gpt-4
VUE_APP_OPENAI_DEPLOYMENT_NAME_GPT35=gpt-35-turbo
VUE_APP_OPENAI_API_VERSION=2023-03-15-preview
~~~

14．実行

~~~
npm run serve
~~~

https://twitter.com/sotongshi/status/1655195483470716928

お疲れ様でした。

# 追記

https://speakerdeck.com/satoshirobatofujimoto/vue2-x-vuetifydetiyatutoapuritukuru

AIミーティング 2023/05/10 #ChatGPT #GPT4 #PaLM のLT資料です

https://osaka-driven-dev.connpass.com/event/282588/

https://kumamcn.connpass.com/event/282983/