企業向けシステムにAIチャット機能を統合する際、既存のソリューションではカスタマイズ性やパフォーマンス面での課題に直面することが多い。特に社内知識ベースや業務支援ツールにおいては、柔軟なUI制御と複数AIモデルの切り替えが不可欠だ。この記事では、Vue3(+TypeScript)を基盤とした高品質なチャットアプリの構築手法について、実践的な視点から解説する。
1. 技術選定とモジュール設計
プロジェクト初期段階での技術スタックの選定は、後の拡張性・保守性に大きく影響する。以下は、開発効率とランタイム性能を両立させるための構成例である。
{
"Frontend": "Vue 3.5 + TypeScript",
"Build Tool": "Vite 6",
"State Management": "Pinia",
"UI Frameworks": ["Element Plus", "Naive UI"],
"Styling": ["TailwindCSS", "SCSS"],
"HTTP Client": "Axios",
"Streaming": "EventSource API",
"Content Rendering": ["markdown-it", "highlight.js"]
}
Viteの採用により、開発サーバーの起動時間とHMR(ホットモジュール置換)が劇的に高速化され、リアルタイムでのUI調整が可能になる。また、TypeScriptによる静的型チェックは、APIレスポンスの解析処理など複雑になりがちなロジックのバグを早期に検出できる。
1.1 機能別モジュール分割
本格的なチャットアプリでは、関心の分離に基づいたモジュール設計が必須となる。主なコンポーネントとその責務は以下の通り。
| モジュール名 | 役割 | 採用パターン |
|---|---|---|
ChatSessionStore |
会話状態の管理 | Pinia + ジェネリック型 |
RenderEngine |
メッセージ描画制御 | 仮想スクロール+差分レンダリング |
DataStreamHandler |
SSEストリーム処理 | Observerパターン+バッファ管理 |
ModelGateway |
外部AIサービス連携 | アダプターパターン |
PersistentCache |
ローカル保存 | IndexedDB + LRUアルゴリズム |
特にModelGatewayでは、OpenAIやClaude、自社LLMなど異なるエンドポイントを持つプロバイダーを共通インターフェースで抽象化することで、将来的なモデル切替コストを最小限に抑えられる。
1.2 ディレクトリ構造のベストプラクティス
チーム開発における可読性向上のために、機能単位ではなくレイヤー単位でディレクトリを整理することを推奨する。
src/
├── core/
│ ├── session/
│ │ ├── useSessionManager.ts
│ │ └── sessionStorage.ts
│ ├── streaming/
│ │ ├── createStreamReader.ts
│ │ └── parseSSEChunk.ts
│ └── models/
│ ├── openaiAdapter.ts
│ └── claudeAdapter.ts
├── ui/
│ ├── components/
│ │ ├── chat-thread.vue
│ │ ├── message-bubble.vue
│ │ └── input-area.vue
│ └── composables/
│ ├── useTypingEffect.ts
│ └── useVirtualScroll.ts
├── store/
│ └── chatSession.ts
├── types/
│ └── index.d.ts
└── utils/
├── markdownRenderer.ts
└── idGenerator.ts
2. パフォーマンス最適化されたUI実装
大量のメッセージを扱うチャットアプリでは、描画パフォーマンスがユーザー体験に直結する。特に履歴保持型の会話では、すべてのDOMノードを常時生成しない工夫が必要だ。
2.1 仮想スクロールによる描画負荷削減
画面内に表示される範囲のみを動的に描画する仮想スクロールを実装することで、数千件の履歴でもスムーズなスクロールが可能になる。
<template>
<div class="scroll-container" ref="scrollRef" @scroll="onScroll">
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="item in visibleItems"
:key="item.id"
:style="{ position: 'absolute', top: item.top + 'px' }"
>
<MessageBubble :data="item.message" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import MessageBubble from '@/ui/components/message-bubble.vue';
import { useSessionManager } from '@/core/session/useSessionManager';
const ITEM_HEIGHT = 80;
const BUFFER_SIZE = 5;
const scrollRef = ref<HTMLElement | null>(null);
const scrollTop = ref(0);
const containerHeight = ref(0);
const { messages } = useSessionManager();
const totalHeight = computed(() => messages.value.length * ITEM_HEIGHT);
const visibleItems = computed(() => {
const startIdx = Math.max(0, Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_SIZE);
const endIdx = Math.min(
messages.value.length,
startIdx + Math.ceil(containerHeight.value / ITEM_HEIGHT) + BUFFER_SIZE * 2
);
return messages.value.slice(startIdx, endIdx).map((msg, idx) => ({
id: msg.id,
message: msg,
top: (startIdx + idx) * ITEM_HEIGHT
}));
});
function onScroll() {
if (scrollRef.value) {
scrollTop.value = scrollRef.value.scrollTop;
}
}
onMounted(() => {
if (scrollRef.value) {
containerHeight.value = scrollRef.value.clientHeight;
}
});
</script>
2.2 ストリーミング応答のリアルタイム反映
Server-Sent Events(SSE)を活用し、AIからの応答を逐次受信してUIを更新する仕組みを構築する。これにより、完全なレスポンスを待たずに途中経過をユーザーに提示できる。
// core/streaming/createStreamReader.ts
export class StreamReader {
private buffer = '';
private decoder = new TextDecoder();
async read(response: Response, onTextChunk: (text: string) => void): Promise<string> {
const reader = response.body?.getReader();
if (!reader) throw new Error('ReadableStream not available');
let completeText = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = this.decoder.decode(value, { stream: true });
this.buffer += chunk;
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data:') && !line.includes('[DONE]')) {
const rawJson = line.slice(5).trim();
if (!rawJson) continue;
try {
const payload = JSON.parse(rawJson);
const content = extractContent(payload); // delta.content or choices[0].text
if (content) {
completeText += content;
onTextChunk(content);
}
} catch (err) {
console.warn('Parse error:', err);
}
}
}
}
} finally {
reader.releaseLock();
}
return completeText;
}
}
このストリームリーダーを使用すると、以下のように非同期処理内でリアルタイム描画が実現できる。
const controller = new AbortController();
const streamReader = new StreamReader();
try {
const res = await fetch('/api/v1/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: userInput }),
signal: controller.signal
});
await streamReader.read(res, (chunk) => {
updateCurrentResponse(chunk); // UI更新
});
} catch (err) {
if (err.name !== 'AbortError') {
handleError(err);
}
}
2.3 拡張可能なメッセージスキーマ
将来の機能追加を見据えて、柔軟なデータモデルを設計しておくことが重要だ。以下は、マルチメディア対応やメタ情報保持を前提としたインターフェース例である。
// types/index.d.ts
interface BaseMessage {
uid: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
status: 'pending' | 'completed' | 'failed';
}
export interface ChatMessage extends BaseMessage {
model?: string;
attachments?: Array<{
type: 'image' | 'file' | 'audio';
name: string;
url: string;
size?: number;
}>;
metadata?: Record<string, any>;
}
export interface ConversationSession {
sessionId: string;
title: string;
messages: ChatMessage[];
createdAt: number;
updatedAt: number;
config: {
provider: string;
parameters: {
temperature: number;
max_tokens: number;
};
};
}