3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

vue.jsとvuetifyを使って簡単なチャットボットUIを作ってみましょう

3
Posted at

この記事では、Vuetify(TypeScript/Vue.js)を使って、LLMのAPIと接続するチャットボットUIを作成する方法について解説します。

はじめに

DeepSeek、Gemini、ChatGPTなどのLLMをチャットボットとして使う場合、最も簡単なのは公式のWebサイトやアプリから直接利用することです。しかし、APIを通じて自分でチャットボットを作ることも可能です。作り方はさまざまありますが、ここではVuetifyを使って実装します。

これまでもVuetifyでWebアプリを作る方法をいくつか紹介してきました。例えば、以前紹介した画像生成AIアプリや画像認識アプリなどです。

今回はチャットボットUIを作ってみます。

ただし、今回使用するAPIは各自で準備する必要があります。詳細については割愛します。

例としてDeepSeek APIを使いますが、GPT APIや、自分でOllamaやLM Studioを使ってローカルに構築したAPIにも同様に応用できます。

一つ言っておきたいこと

実は今回の記事は、これまでと大きく異なる点があります。それは……この記事で扱うコードのほとんどを、AI(DeepSeekとGemini CLI)の力を借りて書いたということです。

2025年頃からDeepSeekをはじめとするチャットボットを使い始め、徐々にその利用頻度が増えてきました。最近では、Google検索でも「AIによる概要」や「AIモード」としてGeminiが普通に使われるようになっています。

ただし、これまでは単にAIから得た情報を参考に、自分でコードを書いてきました。それに対し今回は、最初からDeepSeekが生成したコードをベースに、少しずつ修正していくという方法を初めて試みました。

とはいえ、要件定義や細かい修正指示を出したのは自分ですし、生成されたコードをすべて読み、仕組みを理解した上で利用しています。完全にAI任せというわけではありません。

もし完全にAI生成だけだったなら、記事として書く意味はほとんどない気がします。

コーディングをAIに任せる時代になってきましたが、生成されたコードを理解せずにそのまま使うのは、まだ危険だと思います。

前置きが長くなりましたが、本題に入りましょう。

作った結果

まず、どのようなものができるかを説明します。

ブラウザで開くと、次のような画面が表示されます。

下部の入力欄にテキストを入力し、Enterキーを押すと……

截屏2026-05-05-17.07.07.png

AIが応答します。


右上のアイコンをクリックすると、応答内容をクリップボードにコピーできます。

コードブロックがある場合はシンタックスハイライトされ、右上の「コピー」ボタンでコードをクリップボードにコピーできます。

右上のアイコンには、すべての会話をJSONとして保存するボタンと、会話を削除してリセットするボタンがあります。

要するに、よくある一般的なチャットボットUIです。しかし、実際に自分で実装しようとすると、決して簡単ではありません。

難しいポイントはいくつかあります。

  • メッセージ入力欄では、Enterで送信、Shift+Enterで改行
  • 日本語の漢字変換中のEnterは送信と判定しないように注意
  • AIの応答をストリーミングで少しずつ表示
  • 途中で停止した場合のエラー対応
  • 会話内容をセッションストレージに保存し、同じブラウザで再アクセス時に継続
  • 会話をリセットするボタン
  • AIが出力するMarkdown形式の内容を正しくレンダリング
  • コードブロックの言語に応じたシンタックスハイライト
  • コードブロックごとにコピーボタンを追加
  • コピー時にはハイライト用のHTMLタグを含まない、元のコードのみを取得する工夫

……など、実装にはかなりの手間がかかりました。

本来、これらを自分一人で実装するなら数日かかるところですが、AIの助けにより1日で完成できました。

APIの準備

チャットボットに使えるAPIはさまざまあります。ただし、高性能なAPIのほとんどは有料です。OllamaやLM Studioを使ってローカルにAPIを立てて無料で使うこともできますが、それなりのコンピューターとGPUが必要で、構築の手間もかかります。

そこで、ここでは例として安価に使えるDeepSeek APIを利用します。ちょうど2026年4月22日にDeepSeek v4がリリースされ、話題になりました。GPTやClaudeなどより数倍安いにもかかわらず、高い性能を持つことで好評です。

DeepSeek APIを使うには、まずこちらでアカウントを作成します。

次に支払い(チャージ)を行います。USDまたはCNY(元)で支払い可能です。
https://platform.deepseek.com/top_up

CNYで支払う場合、最低10元(現在約230円)から利用できます。ただし支付宝(Alipay)または微信支付(WeChat Pay)が必要です。これらがない場合は、クレジットカードやPayPalを使ってUSDで支払いできます。

たった230円で余裕を持って使えます。学習用途に適していると思います。

チャージ後、APIキーを発行して保存します。
https://platform.deepseek.com/api_keys

これで準備完了です。

現在使えるモデルは2つです。

  • deepseek-v4-flash
  • deepseek-v4-pro

proの方が性能が高いですが、トークン消費量が数倍になるため、今回は主に軽量なflashを使います。これだけでも十分です。

実装

プロジェクト構築

最近、bunが好評なので、今回はnpmやyarnではなくbunを使うことにしました。基本的な使い方はほぼ同じです。

bun create vuetify

現時点(create-vuetify v3.16)では選択肢が多くありますが、基本的にデフォルトのまま進めます。

Start from a preset? → Start from scratch
Project name: → vuechatui
Which framework would you like to use? → Vue
Which CSS framework? → None
Select features to install: → ESLint, MCP
Do you want to install dependencies? → Yes
Which package manager would you like to use? → bun
Do you want to save these settings as a preset? → no

作成したプロジェクトに移動し、必要なパッケージをインストールします。

cd vuechatui
bun add marked marked-highlight highlight.js katex marked-katex-extension @types/katex

実行した時点で、package.jsondependenciesは次のようになりました。

{
    "@fontsource/roboto": "^5.2.10",
    "@mdi/font": "7.4.47",
    "@types/katex": "^0.16.8",
    "highlight.js": "^11.11.1",
    "katex": "^0.16.45",
    "marked": "^18.0.3",
    "marked-highlight": "^2.2.4",
    "marked-katex-extension": "^5.1.8",
    "vue": "^3.5.30",
    "vuetify": "^4.0.2"
}

ちょうどVuetifyがバージョン4をリリースしたばかりで、不安定な部分もあるようですが、バージョン2から3への移行のような破壊的な変更はなく、使い方はほぼ同じです。Vuetify 3を使っていたプロジェクトもVuetify 4に簡単に移行できます。

全体の構成

プロジェクト作成時にさまざまなファイルが自動生成されるので、それらはそのまま使い、ここでは自分で追加・修正するファイルのみ説明します。
コードの中に解説コメントがたくさん書いてありますが、これも全部AIが丁寧に書いてくれたものです。

├──vuechatui
│   ├── .env
│   ├── index.html
│   ├── src
│   │   ├── plugins
│   │   │   ├── index.ts
│   │   │   └── vuetify.ts
│   │   ├── main.ts
│   │   ├── chatStream.ts
│   │   ├── App.vue
│   │   └── (その他)
│   └── (その他)

修正・追加するコード

.env

VITE_API_URL=https://api.deepseek.com
VITE_API_KEY={{ここで自分のAPIキーを入れて……}}
VITE_API_MODEL=deepseek-v4-flash

API情報はここに記述して管理しやすくします。APIやモデルを変更する場合はこのファイルを修正します。

index.html

index.html
<!doctype html>
<html lang="ja">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>AI Chat UI</title>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css">
</head>

<body>
  <div id="app"></div>
  <script type="module" src="/src/main.ts"></script>
</body>

</html>

最初からあるファイルに、ごくわずかな修正を加えたものです。

src/plugins/index.ts

src/plugins/index.ts
/**
 * `src/main.ts` で自動的に読み込まれるプラグイン登録用ファイル
 */

// 型定義のインポート
import type { App } from "vue";

// 各プラグインのインポート
import vuetify from "./vuetify";

export function registerPlugins (app: App) {
  // Vuetifyを使用するように設定
  app.use(vuetify);
}

最初からあるファイルで、そのまま使えますが、コメントを追加しました。

src/plugins/vuetify.ts

src/plugins/vuetify.ts
/**
 * Vuetifyの設定ファイル
 */

// コンポーザブルのインポート
import { createVuetify } from "vuetify";

// マテリアルデザインアイコンのスタイルをインポート
import "@mdi/font/css/materialdesignicons.css";

// Vuetifyの基本スタイルをインポート
import "vuetify/styles";

// Vuetifyのインスタンスを作成してエクスポート
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
  // テーマの設定
  theme: {
    // デフォルトのテーマをダークモードに設定
    defaultTheme: "dark",
    themes: {
      dark: {
        // ダークテーマのカラーパレット定義
        colors: {
          background: "#1e1e2e", // 背景色
          surface: "#2a2a3c",    // カードなどの表面色
          primary: "#7c3aed",    // 主要な色(紫系)
          secondary: "#a78bfa",  // 補助的な色(薄紫系)
        },
      },
    },
  },
});

このファイルではテーマの色設定を記述します。

src/main.ts

src/main.ts
/**
 * Vuetifyやその他のプラグインを初期化し、アプリケーションをマウント
 */

// コンポーザブルのインポート
import { createApp } from "vue";

// プラグイン登録関数のインポート
import { registerPlugins } from "@/plugins";

// メインのAppコンポーネントをインポート
import App from "./App.vue";

// フォントなどのスタイルをインポート
import "unfonts.css";

// Vueアプリケーションインスタンスを作成
const app = createApp(App);

// インポートしたプラグイン(Vuetifyなど)をアプリケーションに登録
registerPlugins(app);

// HTML内の#app要素にアプリケーションをマウント
app.mount("#app");

src/chatStream.ts

APIとのやり取りを定義する関数はここに記述します。

src/chatStream.ts
import { ref } from "vue";

/**
 * メッセージの型定義
 */
interface Message {
  id: string;          // 一意識別子
  role: "user" | "assistant"; // 送信者のロール
  content: string;     // メッセージ内容
  timestamp: Date;     // 送信日時
}

/**
 * チャットのストリーミング機能を管理するカスタムフック
 */
export function useChatStream() {
  const message_list = ref<Message[]>([]); // メッセージ履歴のリアクティブな配列
  const nowStreaming = ref(false);          // ストリーミング中かどうか
  const error = ref<string | null>(null);   // エラーメッセージ
  const abortController = ref<AbortController | null>(null); // リクエスト中断用のコントローラー
  const currentReader = ref<ReadableStreamDefaultReader<Uint8Array> | null>(null); // 現在のストリームリーダー
  const firstMessage = "こんにちは、何かお手伝いできることはありますか?";

  const STORAGE_KEY = "chat_history"; // セッションストレージのキー

  /**
   * メッセージ履歴をセッションストレージに保存する
   */
  const saveToStorage = () => {
    sessionStorage.setItem(STORAGE_KEY, JSON.stringify(message_list.value));
  };

  /**
   * セッションストレージからメッセージ履歴を読み込む
   */
  const loadFromStorage = () => {
    const saved = sessionStorage.getItem(STORAGE_KEY);
    if (saved) {
      try {
        const parsed = JSON.parse(saved);
        // 保存された日時をDate型に復元
        message_list.value = parsed.map((m: any) => ({
          ...m,
          timestamp: new Date(m.timestamp),
        }));
      } catch (e) {
        console.error("保存されたチャットの解析に失敗しました:", e);
      }
    } else {
      // 保存データがない場合は初期メッセージを追加
      addMessage("assistant", firstMessage);
    }
  };

  /**
   * ランダムなIDを生成する
   */
  const generateId = () => Math.random().toString(36).substring(7);

  /**
   * メッセージをリストに追加する
   */
  const addMessage = (role: "user" | "assistant", content: string) => {
    const message: Message = {
      id: generateId(),
      role,
      content,
      timestamp: new Date(),
    };
    message_list.value.push(message);
    saveToStorage();
    return message;
  };

  // 初期化時にストレージから読み込み
  loadFromStorage();

  /**
   * メッセージを送信し、AIの回答をストリーミングで取得する
   */
  const sendMessage = async (userInput: string) => {
    if (!userInput.trim() || nowStreaming.value) return;

    error.value = null;
    abortController.value = new AbortController();

    // ユーザーのメッセージを追加
    addMessage("user", userInput);
    // AIの空メッセージを追加(ここにストリーミング内容が追記される)
    const assistantMessage = addMessage("assistant", "");
    nowStreaming.value = true;

    try {
      const apiKey = import.meta.env.VITE_API_KEY || "";
      const apiUrl = (import.meta.env.VITE_API_URL || "") + "/chat/completions";
      const model = import.meta.env.VITE_API_MODEL || "";
      
      const response = await fetch(apiUrl, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${apiKey}`,
        },
        body: JSON.stringify({
          model,
          messages: message_list.value
            .filter(m => m.role !== "assistant" || m.content !== "") // 空のメッセージを除外
            .map(m => ({
              role: m.role,
              content: m.content,
            })),
          stream: true,
        }),
        signal: abortController.value.signal, // 中断信号を渡す
      });

      if (!response.ok) {
        throw new Error(`APIエラー: ${response.statusText}`);
      }

      const reader = response.body?.getReader();
      const decoder = new TextDecoder();

      if (!reader) throw new Error("リーダーを取得できませんでした");
      currentReader.value = reader; // リーダーを保持

      let buffer = "";
      // ストリームを逐次読み取り
      while (true) {
        // 中断チェック
        if (abortController.value?.signal.aborted) {
          break;
        }

        const { done, value } = await reader.read();
        if (done) break;

        // デコードした内容をバッファに追加
        buffer += decoder.decode(value, { stream: true });
        
        // 改行で分割
        const lines = buffer.split("\n");
        // 最後の要素は(改行で終わっていない限り)不完全な行の可能性があるため、バッファに残す
        buffer = lines.pop() || "";

        for (const line of lines) {
          const trimmedLine = line.trim();
          if (!trimmedLine || !trimmedLine.startsWith("data: ")) continue;

          const data = trimmedLine.slice(6).trim();
          if (data === "[DONE]") {
            nowStreaming.value = false;
            return;
          }

          try {
            const parsed = JSON.parse(data);
            const content = parsed.choices[0]?.delta?.content;
            if (content) {
              // AIのメッセージ内容を更新
              const idx = message_list.value.findIndex(m => m.id === assistantMessage.id);
              if (idx !== -1) {
                message_list.value[idx].content += content;
                saveToStorage();
              }
            }
          } catch (e) {
            // パースエラーログは出すが、ストリームは継続する(次の行で直る可能性があるため)
            console.warn("不完全なデータ行をスキップしました:", data, e);
          }
        }
      }
    } catch (err: any) {
      if (err.name === "AbortError" || abortController.value?.signal.aborted) {
        error.value = "ストリーミングが中断されました";
      } else {
        error.value = err.message;
        console.error("ストリーミングエラー:", err);
      }
      // エラーがあり、AIのメッセージが空のままなら削除
      const idx = message_list.value.findIndex(m => m.id === assistantMessage.id);
      if (idx !== -1 && !message_list.value[idx].content) {
        message_list.value.splice(idx, 1);
      }
      saveToStorage();
    } finally {
      nowStreaming.value = false;
      if (currentReader.value) {
        try {
          currentReader.value.releaseLock();
        } catch (e) {}
      }
      currentReader.value = null;
      abortController.value = null;
    }
  };

  /**
   * 現在のストリーミングを中断する
   */
  const cancelStream = () => {
    if (abortController.value) {
      abortController.value.abort();
    }
    if (currentReader.value) {
      try {
        currentReader.value.cancel();
      } catch (e) {
        console.error("リーダーのキャンセルに失敗:", e);
      }
    }
    nowStreaming.value = false;
  };

  /**
   * メッセージ履歴を全消去する
   */
  const clearMessage = async () => {
    message_list.value = [];
    sessionStorage.removeItem(STORAGE_KEY);
    error.value = null;

    // 演出のため少し待ってから初期メッセージを表示
    await new Promise(resolve => setTimeout(resolve, 500));
    addMessage("assistant", firstMessage);
  };

  return {
    message_list,
    nowStreaming,
    error,
    sendMessage,
    cancelStream,
    clearMessage,
  };
}

src/App.vue

UIの構造や見た目はすべてここに記述します。特にコンポーネントに分割する必要はないと判断し、簡単のため一つのファイルに纏めました。

src/App.vue
<template>
  <v-app>
    <v-main>
      <!-- メインのチャットカード。中央に配置 -->
      <v-card class="chat-container mx-auto" elevation="8">
        <!-- ツールバー:タイトルと操作ボタン -->
        <v-toolbar color="primary" density="compact">
          <v-toolbar-title>AIとお喋り</v-toolbar-title>
          <v-spacer></v-spacer>
          <!-- 履歴ダウンロードボタン -->
          <v-btn icon="mdi-download" variant="text" @click="downloadHistory" :disabled="message_list.length === 0"
            title="履歴をJSONでダウンロード"></v-btn>
          <!-- 履歴クリアボタン -->
          <v-btn icon="mdi-delete" variant="text" @click="clearMessage" :disabled="message_list.length === 0"
            title="履歴をクリア"></v-btn>
        </v-toolbar>

        <!-- メッセージ表示エリア -->
        <v-card-text class="messages-container" ref="messagesContainer">
          <div v-for="message in message_list" :key="message.id" class="mb-4">
            <!-- ユーザーとAIで左右に出し分け -->
            <div :class='["d-flex", message.role === "user" ? "justify-end" : "justify-start"]'>
              <div :class='["message-bubble", message.role]'>
                <!-- AI側のヘッダー(アイコンと名前) -->
                <div v-if='message.role === "assistant"' class="d-flex align-center mb-1">
                  <v-icon size="small" color="primary" class="mr-2">mdi-robot</v-icon>
                  <span class="text-caption font-weight-bold">[AIさん]</span>
                  <v-spacer></v-spacer>
                  <!-- メッセージコピーボタン -->
                  <v-btn icon="mdi-content-copy" variant="text" size="x-small" density="comfortable"
                    @click="copyToClipboard(message.content)" title="メッセージをコピー"></v-btn>
                </div>
                <!-- ユーザー側のヘッダー -->
                <div v-else class="d-flex align-center mb-1 justify-end">
                  <v-btn icon="mdi-content-copy" variant="text" size="x-small" density="comfortable" class="mr-1"
                    @click="copyToClipboard(message.content)" title="メッセージをコピー"></v-btn>
                  <span class="text-caption font-weight-bold">[わたし]</span>
                  <v-icon size="small" class="ml-2">mdi-account</v-icon>
                </div>
                <!-- メッセージ本文。Markdown形式をHTMLとして描画 -->
                <div class="message-content" v-html="formatMessage(message.content)" @click="handleContentClick"></div>
                <!-- 送信時間 -->
                <div class="text-caption text-medium-emphasis mt-1 text-right">
                  {{ formatTime(message.timestamp) }}
                </div>
              </div>
            </div>
          </div>

          <!-- ストリーミング中の入力中アニメーション -->
          <div v-if="nowStreaming" class="d-flex justify-start mb-4">
            <div class="typing-indicator">
              <span></span><span></span><span></span>
            </div>
          </div>

          <!-- エラー通知アラート -->
          <v-alert v-if="error" type="error" variant="tonal" closable class="mt-4" @click:close="error = null">
            {{ error }}
          </v-alert>
        </v-card-text>

        <!-- 入力エリア -->
        <v-card-actions class="pa-4">
          <v-form @submit.prevent="handleSend" class="flex-grow-1">
            <v-textarea v-model="userInput" placeholder="ここにメッセージを入力..." variant="outlined" density="comfortable"
              hide-details :readonly="nowStreaming" :loading="nowStreaming" auto-grow rows="1" max-rows="5"
              @keydown.enter.exact.prevent="handleSend">
              <template v-slot:append-inner>
                <!-- 送信ボタン(ストリーミング中でない時) -->
                <v-btn v-if="!nowStreaming" icon="mdi-send" variant="text" color="primary" @click="handleSend"
                  :disabled="!userInput.trim()"></v-btn>
                <!-- 中断ボタン(ストリーミング中) -->
                <v-btn v-else icon="mdi-stop" type="button" variant="text" color="error" 
                  @click.stop.prevent="cancelStream" @mousedown.stop></v-btn>
              </template>
            </v-textarea>
          </v-form>
        </v-card-actions>
      </v-card>
    </v-main>
  </v-app>
</template>

<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { useChatStream } from "./chatStream";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";
import markedKatex from "marked-katex-extension";
import hljs from "highlight.js";
import "highlight.js/styles/github-dark.css"; // コードハイライト用のテーマ

/**
 * markedのインスタンス作成とシンタックスハイライトの設定
 */
const marked = new Marked(
  markedHighlight({
    langPrefix: "hljs language-",
    highlight(code, lang) {
      // 指定された言語がhighlight.jsでサポートされているか確認
      const language = hljs.getLanguage(lang) ? lang : "plaintext";
      return hljs.highlight(code, { language }).value;
    }
  })
);

// KaTeX拡張の登録
marked.use(markedKatex({
  throwOnError: false
}));

/**
 * コードブロック内に「コピー」ボタンを追加するためのカスタムレンダラー
 */
const renderer = {
  code(token: { text: string; lang?: string }): string {
    const code = token.text;
    const lang = (token.lang || "").match(/\S*/)?.[0];

    // ハイライトされたコードを取得
    // @ts-ignore
    const highlighted = this.options.highlight ? this.options.highlight(code, lang)
      : code;

    // ボタン付きのHTML構造を返す
    return `
      <div class="code-container">
        <div class="code-header">
          <span class="code-lang">${lang || "text"}</span>
          <button class="copy-code-btn">
            <span class="mdi mdi-content-copy"></span> コピー
          </button>
        </div>
        <pre><code class="hljs language-${lang}">${highlighted}</code></pre>
      </div>
    `;
  }
};

// レンダラーをmarkedに登録
marked.use({ renderer } as any);

// 基本的なMarkdownオプションの設定
marked.setOptions({
  gfm: true,     // GitHub Flavored Markdownを有効化
  breaks: true   // 改行を<br>に変換
});

// チャットストリーム管理用のカスタムフックから各種値と関数を抽出
const { message_list, nowStreaming, error, sendMessage, cancelStream, clearMessage } = useChatStream();

const userInput = ref(""); // ユーザーの入力テキスト

/**
 * テキストをクリップボードにコピーする
 */
const copyToClipboard = async (text: string) => {
  try {
    await navigator.clipboard.writeText(text);
  } catch (err) {
    console.error("コピーに失敗しました: ", err);
  }
};

/**
 * メッセージ本文内のクリックイベントをハンドリング(コードコピーボタン用)
 */
const handleContentClick = (event: MouseEvent) => {
  const target = event.target as HTMLElement;
  const btn = target.closest(".copy-code-btn") as HTMLElement;
  if (btn) {
    // 同じコンテナ内の<code>要素を探す
    const container = btn.closest(".code-container");
    const codeElement = container?.querySelector("code");

    if (codeElement) {
      // textContentを取得。これによりHTMLタグは無視され、純粋なテキストのみが取得される
      const code = codeElement.textContent || "";
      copyToClipboard(code);

      // コピー成功時の視覚的なフィードバック
      const originalHtml = btn.innerHTML;
      btn.innerHTML = '<span class="mdi mdi-check"></span> コピーした!';

      setTimeout(() => {
        btn.innerHTML = originalHtml;
      }, 2000);
    }
  }
};

/**
 * チャット履歴をJSONファイルとしてダウンロードする
 */
const downloadHistory = () => {
  const data = JSON.stringify(message_list.value, null, 2);
  const blob = new Blob([data], { type: "application/json" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `chat-history-${new Date().toISOString().slice(0, 10)}.json`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
};

/**
 * メッセージ送信ボタンのハンドラ
 */
const handleSend = async () => {
  if (!userInput.value.trim() || nowStreaming.value) return;
  const message = userInput.value;
  userInput.value = ""; // 入力欄をクリア
  await sendMessage(message); // API呼び出し
};

/**
 * MarkdownテキストをHTMLに変換する
 */
const formatMessage = (content: string) => {
  return marked.parse(content);
};

/**
 * 時刻を指定のフォーマット(時:分)で整形する
 */
const formatTime = (date: Date) => {
  return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
</script>

<style scoped>
/* チャット全体のコンテナ設定 */
.chat-container {
  max-width: 800px;
  height: 90vh;
  display: flex;
  flex-direction: column;
}

/* メッセージ表示エリア。スクロール可能 */
.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: rgb(var(--v-theme-background));
}

/* メッセージの吹き出し */
.message-bubble {
  max-width: 85%;
  padding: 12px 16px;
  border-radius: 16px;
  word-wrap: break-word;
}

/* ユーザーのメッセージ(右側) */
.message-bubble.user {
  background: rgb(var(--v-theme-primary));
  color: white;
  border-bottom-right-radius: 4px;
}

/* AIのメッセージ(左側) */
.message-bubble.assistant {
  background: rgb(var(--v-theme-surface));
  border: 1px solid rgba(var(--v-theme-primary), 0.2);
  border-bottom-left-radius: 4px;
}

.message-content {
  line-height: 1.6;
}

/* コードブロックの装飾 */
.message-content :deep(.code-container) {
  position: relative;
  margin: 12px 0;
  border-radius: 8px;
  overflow: hidden;
  background: #0d1117;
}

.message-content :deep(.code-header) {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 4px 12px;
  background: rgba(255, 255, 255, 0.05);
  font-family: sans-serif;
  font-size: 0.8em;
  color: #8b949e;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

/* コードコピーボタン */
.message-content :deep(.copy-code-btn) {
  cursor: pointer;
  background: transparent;
  border: none;
  color: #8b949e;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 2px 6px;
  border-radius: 4px;
  transition: all 0.2s;
}

.message-content :deep(.copy-code-btn:hover) {
  background: rgba(255, 255, 255, 0.1);
  color: white;
}

.message-content :deep(pre) {
  padding: 0;
  margin: 0;
}

.message-content :deep(pre code) {
  display: block;
  padding: 16px;
  font-family: "Fira Code", "Courier New", monospace;
  font-size: 0.9em;
  overflow-x: auto;
  background: transparent;
}

/* インラインコード */
.message-content :deep(code:not(pre code)) {
  font-family: "Fira Code", "Courier New", monospace;
  font-size: 0.9em;
  background: rgba(var(--v-theme-primary), 0.1);
  padding: 2px 4px;
  border-radius: 4px;
  color: rgb(var(--v-theme-primary));
}

.message-content :deep(p) {
  margin-bottom: 8px;
}

.message-content :deep(p:last-child) {
  margin-bottom: 0;
}

/* リスト形式のスタイル */
.message-content :deep(ul),
.message-content :deep(ol) {
  margin-left: 20px;
  margin-bottom: 8px;
}

.message-content :deep(h1),
.message-content :deep(h2),
.message-content :deep(h3) {
  margin-top: 12px;
  margin-bottom: 8px;
  line-height: 1.2;
}

/* KaTeX 数式のスタイル調整 */
.message-content :deep(.katex-display) {
  margin: 12px 0;
  overflow-x: auto;
  overflow-y: hidden;
}

.message-content :deep(.katex) {
  font-size: 1.1em;
}

/* 入力中アニメーション(ドットが弾むやつ) */
.typing-indicator {
  background: rgb(var(--v-theme-surface));
  padding: 12px 16px;
  border-radius: 16px;
  border: 1px solid rgba(var(--v-theme-primary), 0.2);
}

.typing-indicator span {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: rgb(var(--v-theme-primary));
  margin: 0 2px;
  animation: typing 1.4s infinite both;
}

.typing-indicator span:nth-child(2) {
  animation-delay: 0.2s;
}

.typing-indicator span:nth-child(3) {
  animation-delay: 0.4s;
}

@keyframes typing {

  0%,
  60%,
  100% {
    transform: translateY(0);
    opacity: 0.4;
  }

  30% {
    transform: translateY(-10px);
    opacity: 1;
  }
}
</style>

実行

準備が整ったら、開発モードで実行します。

bun dev

これでhttp://localhost:3000/にアクセスして確認できます。

開発が終わったら、本番用にビルドします。

bun run build

終わりに

今回は、VuetifyとLLM APIを連携したチャットボットUIの実装方法を紹介しました。AIが生成したコードをベースにしながらも、自分で内容を理解し、改良していくことの重要性を改めて感じました。今後は更に多くの機能を追加することで、より実用的なチャットボットに発展させられるでしょう。この記事が、自分のチャットボットUIを作る際の参考になれば幸いです。

3
3
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
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?