この記事では、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キーを押すと……
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.jsonのdependenciesは次のようになりました。
{
"@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
<!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/main.ts` で自動的に読み込まれるプラグイン登録用ファイル
*/
// 型定義のインポート
import type { App } from "vue";
// 各プラグインのインポート
import vuetify from "./vuetify";
export function registerPlugins (app: App) {
// Vuetifyを使用するように設定
app.use(vuetify);
}
最初からあるファイルで、そのまま使えますが、コメントを追加しました。
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
/**
* 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とのやり取りを定義する関数はここに記述します。
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の構造や見た目はすべてここに記述します。特にコンポーネントに分割する必要はないと判断し、簡単のため一つのファイルに纏めました。
<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を作る際の参考になれば幸いです。







