Lecture 1プロジェクト設計 — チャットボットの構成を考える

12:00

プロジェクト設計 — チャットボットの構成を考える

チャットボットを作り始める前に、まずは全体像を把握しましょう。どんなアプリケーションでも、設計なしにコードを書き始めると途中で行き詰まります。この講義では、これから作るチャットボットの全体アーキテクチャ、使用する技術スタック、プロジェクトのフォルダ構成を Claude Code を使って一緒に決めていきます。完成イメージを明確にしてから開発に入ることで、迷いなく進められるようになります。

チャットボットの全体アーキテクチャ

今回作るチャットボットは、3つの主要コンポーネントで構成されます。

フロントエンド(ブラウザ): ユーザーが直接操作する画面です。HTML/CSS/JavaScript で構築し、チャットの入力欄、メッセージの表示エリア、送信ボタンなどを実装します。ユーザーが入力したメッセージをバックエンドに送信し、AIからの応答を画面に表示する役割を担います。

バックエンド(Express.js サーバー): フロントエンドとClaude APIの橋渡し役です。Node.js + Express.js で構築します。フロントエンドからのリクエストを受け取り、Claude API に転送し、応答をフロントエンドに返します。APIキーをサーバー側で安全に管理するという重要な役割もあります。

Claude API(Anthropic): Anthropic が提供する AI API です。メッセージを送ると、Claude が応答を生成して返してくれます。直接フロントエンドから呼ぶこともできますが、APIキーの漏洩を防ぐためにバックエンド経由で通信します。

データの流れは次のようになります。

[ユーザー] → [フロントエンド] → [Express サーバー] → [Claude API]
                                                          
[ユーザー] ← [フロントエンド] ← [Express サーバー] ← [Claude 応答]

この3層構造は、Web アプリケーションの基本パターンです。フロントエンドがプレゼンテーション層、Express がアプリケーション層、Claude API が外部サービス層にあたります。

技術スタックの選定

Claude Code に技術スタックの選定を相談してみましょう。

> チャットボットWebアプリの技術スタックを提案して。
  要件:
  - 初心者でも理解しやすいシンプルな構成
  - フロントエンドはフレームワークなし(HTML/CSS/JS)
  - バックエンドはNode.js
  - Claude APIを使用
  それぞれの理由も説明して

Claude Code が提案する技術スタックは以下の通りです。

レイヤー 技術 選定理由
フロントエンド HTML / CSS / JavaScript フレームワークの学習コスト不要。基礎技術のみで構築
バックエンド Node.js + Express.js JavaScript統一で学習効率が高い。軽量で高速
AI API Anthropic Claude API 高品質な日本語対応。明確なメッセージ形式
パッケージ管理 npm Node.js標準のパッケージマネージャ
環境変数管理 dotenv APIキーを安全に管理するための定番ライブラリ
HTTP通信 @anthropic-ai/sdk Anthropic公式のNode.js SDK
CORS対応 cors フロントエンドとバックエンドの通信を許可

React や Vue.js を使わない理由は、チャットボットの仕組み自体を理解することに集中するためです。フレームワークを入れると、フレームワーク自体の学習に時間を取られてしまいます。まずは素のHTML/CSS/JavaScriptで動くものを作り、仕組みを理解してからフレームワークに移行するのが効率的です。

プロジェクトフォルダ構成を作る

Claude Code にプロジェクトのフォルダ構成を作ってもらいましょう。

> チャットボットのプロジェクトフォルダ構成を作って。
  - フロントエンドは public/ フォルダ
  - バックエンドは server/ フォルダ
  - 設定ファイルはルート直下
  必要なファイルもすべてリストアップして

Claude Code が生成する構成は次の通りです。

chatbot-app/
├── public/                  # フロントエンド
   ├── index.html           # メインページ
   ├── css/
      └── style.css        # スタイルシート
   └── js/
       └── app.js           # クライアントサイドJS
├── server/                  # バックエンド
   └── index.js             # Expressサーバー
├── .env                     # 環境変数APIキー
├── .gitignore               # Git除外設定
├── package.json             # 依存パッケージ管理
└── README.md                # プロジェクト説明

各ファイルの役割を確認しましょう。

  • public/index.html: チャット画面のHTML構造を定義します
  • public/css/style.css: チャットUIの見た目を装飾します
  • public/js/app.js: メッセージの送受信やUI更新のロジックを書きます
  • server/index.js: Express サーバーとClaude API連携のコードを書きます
  • .env: ANTHROPIC_API_KEY を保存する秘密のファイルです
  • .gitignore: .envnode_modules をGitに含めないための設定です

Claude Code でプロジェクトを初期化する

実際にプロジェクトを作成していきましょう。Claude Code にプロジェクトの初期化を依頼します。

> chatbot-app というフォルダを作ってnpm init でpackage.jsonを生成して
  必要なパッケージもインストールして
  パッケージ: express, cors, dotenv, @anthropic-ai/sdk
  開発用: nodemon

Claude Code が実行するコマンドは以下のようになります。

mkdir -p chatbot-app/{public/{css,js},server}
cd chatbot-app
npm init -y
npm install express cors dotenv @anthropic-ai/sdk
npm install --save-dev nodemon

次に .gitignore を作成します。

> .gitignore を作って。node_modules と .env を除外して
node_modules/
.env
.DS_Store

package.jsonscripts セクションも更新します。

> package.json  scripts に以下を追加して:
  - "start": "node server/index.js"
  - "dev": "nodemon server/index.js"
{
  "scripts": {
    "start": "node server/index.js",
    "dev": "nodemon server/index.js"
  }
}

npm run dev で開発サーバーを起動し、ファイルを変更すると自動的にサーバーが再起動します。nodemon は開発時の効率を大幅に向上させてくれるツールです。

演習問題

  1. プロジェクト作成: Claude Code を使って chatbot-app プロジェクトを実際に作成してください。フォルダ構成、npm init、パッケージインストールまで完了させましょう。

  2. アーキテクチャ図の作成: Claude Code に「今回のチャットボットのアーキテクチャ図をMermaid記法で書いて」と依頼して、データフローを図式化してみてください。

  3. 技術比較: Claude Code に「Express.js と Fastify の違いを比較して」と聞いて、他のフレームワークの選択肢についても調べてみましょう。

  4. .env.example の作成: Claude Code に「.env.example を作って。必要な環境変数をコメント付きで書いて」と依頼して、チームメンバーが環境構築しやすいようにテンプレートを用意しましょう。

参考資料

Lecture 2Claude API入門 — APIキーの取得と最初のリクエスト

12:00

Claude API入門 — APIキーの取得と最初のリクエスト

チャットボットの頭脳となるのが Claude API です。この講義では、Anthropic Console でAPIキーを取得し、環境変数として安全に設定し、Node.js から最初のAPIリクエストを送信するところまでを実践します。Claude Code を使って、APIとの通信コードを効率的に書いていきましょう。APIの仕組みを理解することが、チャットボット開発の土台になります。

Anthropic Console でAPIキーを取得する

Claude API を使うには、まず Anthropic Console でアカウントを作成し、APIキーを発行する必要があります。

手順 1: console.anthropic.com にアクセスして、アカウントを作成します。メールアドレスとパスワードで登録できます。

手順 2: ログイン後、左メニューの「API Keys」をクリックします。

手順 3: 「Create Key」ボタンを押して、新しいAPIキーを生成します。キーの名前は「chatbot-app」など、用途がわかる名前をつけましょう。

手順 4: 表示されたAPIキーをコピーします。このキーは一度しか表示されないので、すぐに安全な場所に保存してください。

APIキーは sk-ant-api03-... のような形式です。このキーは絶対に公開してはいけません。GitHub にプッシュしたり、フロントエンドのコードに直接書いたりすると、第三者に悪用される可能性があります。

料金について: Claude API は従量課金制です。入力トークンと出力トークンそれぞれに料金がかかります。Claude 3.5 Sonnet の場合、入力が100万トークンあたり3ドル、出力が100万トークンあたり15ドルです。個人の学習用途であれば、月額数ドル程度で収まることがほとんどです。Anthropic Console の「Usage」ページで利用量をリアルタイムに確認できます。

.env ファイルにAPIキーを設定する

Claude Code を使って、APIキーを安全に管理する仕組みを作りましょう。

> .env ファイルを作って、ANTHROPIC_API_KEY を設定する形式にして。
  コメントで説明も入れて。
  .gitignore に .env が含まれているか確認して

Claude Code が生成する .env ファイルの内容です。

# Anthropic API Key
# https://console.anthropic.com/ で取得
ANTHROPIC_API_KEY=sk-ant-api03-ここにあなたのAPIキーを貼り付け

# サーバー設定
PORT=3000

ここで重要なのは、.env ファイルが .gitignore に含まれていることを確認することです。前の講義で設定済みですが、念のため確認しましょう。

> .gitignore の内容を見せて。.env が含まれているか確認して

Anthropic SDK の基本的な使い方

Claude Code に、Anthropic SDK の基本的な使い方を教えてもらいましょう。

> Node.js で Anthropic SDK を使って Claude API にメッセージを送るサンプルコードを書いて。
  - dotenv で .env からAPIキーを読み込む
  - claude-3-5-sonnet モデルを使う
  - 日本語で「こんにちは」と送る
  - 応答をコンソールに表示する

Claude Code が生成するサンプルコードです。

// test-api.js - Claude API 動作確認用スクリプト
require('dotenv').config();
const Anthropic = require('@anthropic-ai/sdk');

const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

async function main() {
  const message = await client.messages.create({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 1024,
    messages: [
      {
        role: 'user',
        content: 'こんにちは!あなたは誰ですか?'
      }
    ],
  });

  console.log('Claude の応答:');
  console.log(message.content[0].text);
  console.log('\n--- メタデータ ---');
  console.log('モデル:', message.model);
  console.log('入力トークン:', message.usage.input_tokens);
  console.log('出力トークン:', message.usage.output_tokens);
  console.log('終了理由:', message.stop_reason);
}

main().catch(console.error);

このスクリプトを実行してみましょう。

node test-api.js

正常に動作すると、Claude からの応答とトークン使用量が表示されます。usage オブジェクトで入出力それぞれのトークン数を確認できるので、コスト管理にも役立ちます。

メッセージ形式を理解する

Claude API のメッセージ形式は、チャットボット開発において非常に重要です。Claude Code に詳しく聞いてみましょう。

> Claude API の messages パラメータの形式を詳しく説明して。
  role の種類と、マルチターン会話の例も見せて

Claude API のメッセージは rolecontent のペアで構成されます。

const messages = [
  { role: 'user', content: 'JavaScriptとは何ですか?' },
  { role: 'assistant', content: 'JavaScriptは、Webブラウザ上で動作するプログラミング言語です...' },
  { role: 'user', content: 'どうやって学べますか?' },
];

role の種類: - user: ユーザーからのメッセージ。人間が入力したテキストです - assistant: Claude からの応答。過去のClaude の応答を含めることで会話の文脈を維持します

メッセージは必ず user で始まり、userassistant が交互に並ぶ必要があります。この交互の並びがチャットボットの会話履歴そのものになります。

主要なパラメータ:

const response = await client.messages.create({
  model: 'claude-sonnet-4-20250514',  // 使用するモデル
  max_tokens: 1024,              // 最大出力トークン数
  system: 'あなたは親切なアシスタントです',  // システムプロンプト
  messages: messages,            // 会話履歴
  temperature: 0.7,              // ランダム性(0〜1)
});

temperature は応答のランダム性を制御します。0に近いほど決定的な応答になり、1に近いほど創造的な応答になります。チャットボットでは 0.7 程度が自然な会話に適しています。

max_tokens は応答の最大長を制限します。長すぎる応答を防ぐために設定しますが、小さすぎると応答が途中で切れてしまうので注意が必要です。

エラーハンドリング

API 呼び出しは常に失敗する可能性があるため、適切なエラーハンドリングが必要です。

> Claude API のエラーハンドリングのベストプラクティスを教えて。
  よくあるエラーコードとその対処法もリストアップして
async function callClaude(messages) {
  try {
    const response = await client.messages.create({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 1024,
      messages: messages,
    });
    return response;
  } catch (error) {
    if (error.status === 401) {
      console.error('認証エラー: APIキーが無効です');
    } else if (error.status === 429) {
      console.error('レート制限: リクエストが多すぎます。少し待ってから再試行してください');
    } else if (error.status === 500) {
      console.error('サーバーエラー: Anthropic側の問題です。しばらく待ってから再試行してください');
    } else {
      console.error('予期しないエラー:', error.message);
    }
    throw error;
  }
}

特に重要なのは 401(APIキーの間違い)と 429(レート制限)です。レート制限に達した場合は、数秒待ってから再試行する「リトライロジック」を実装すると良いでしょう。

演習問題

  1. API動作確認: test-api.js を作成して実行し、Claude API が正常に動作することを確認してください。応答とトークン数を確認しましょう。

  2. パラメータ実験: temperature を 0、0.5、1.0 に変えて同じ質問を送り、応答の違いを観察してください。

  3. マルチターン会話: 3ターン以上の会話を messages 配列で構成し、Claude が文脈を理解しているか確認してください。

  4. エラーテスト: 意図的に間違ったAPIキーを設定して、エラーハンドリングが正しく動作するか確認してください。

参考資料

Lecture 3バックエンド構築 — Express.jsでAPIサーバーを作る

12:00

バックエンド構築 — Express.jsでAPIサーバーを作る

チャットボットのバックエンドを構築します。Express.js を使って API サーバーを立ち上げ、フロントエンドからのリクエストを受け取り、Claude API と通信して応答を返す仕組みを作ります。この講義が終わる頃には、Postman やcurl でテストできる完全に動作するAPIエンドポイントが完成します。Claude Code を使って効率的にサーバーコードを書いていきましょう。

Express.js サーバーの基本セットアップ

まず、Claude Code に Express サーバーの基本構造を作ってもらいます。

> Express.jsでClaude APIと通信するバックエンドを作って。
  server/index.js に書いて。
  要件:
  - dotenv で環境変数を読み込む
  - CORS を有効にする
  - public/ フォルダを静的ファイルとして配信する
  - ポート3000で起動する
  - 起動時にコンソールにURLを表示する

Claude Code が生成する server/index.js の基本構造です。

// server/index.js
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const Anthropic = require('@anthropic-ai/sdk');

const app = express();
const PORT = process.env.PORT || 3000;

// ミドルウェア設定
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public')));

// Anthropic クライアント初期化
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// ルートページ
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});

// サーバー起動
app.listen(PORT, () => {
  console.log(`サーバー起動: http://localhost:${PORT}`);
  console.log('Ctrl+C で停止');
});

ここで使っている各ミドルウェアの役割を整理します。

  • cors(): Cross-Origin Resource Sharing を許可します。フロントエンドとバックエンドが異なるポートで動いている場合に必要です
  • express.json(): リクエストボディの JSON を自動的にパースします。req.body でアクセスできるようになります
  • express.static(): public/ フォルダ内のファイルを静的に配信します。HTMLやCSS、JavaScriptファイルをブラウザに返します

/api/chat エンドポイントの作成

チャットボットの核心となる API エンドポイントを作りましょう。

> /api/chat エンドポイントを追加して。
  POSTリクエストで messages 配列を受け取り、
  Claude API にメッセージを送信して結果を返す。
  エラーハンドリングもしっかり入れて

Claude Code が生成するエンドポイントのコードです。

// チャット API エンドポイント
app.post('/api/chat', async (req, res) => {
  try {
    const { messages, system } = req.body;

    // 入力バリデーション
    if (!messages || !Array.isArray(messages) || messages.length === 0) {
      return res.status(400).json({
        error: 'messages は空でない配列である必要があります',
      });
    }

    // メッセージ形式のバリデーション
    for (const msg of messages) {
      if (!msg.role || !msg.content) {
        return res.status(400).json({
          error: '各メッセージには role と content が必要です',
        });
      }
      if (!['user', 'assistant'].includes(msg.role)) {
        return res.status(400).json({
          error: 'role は "user" または "assistant" である必要があります',
        });
      }
    }

    // Claude API にリクエスト送信
    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 2048,
      system: system || 'あなたは親切で知識豊富なアシスタントです。日本語で回答してください。',
      messages: messages,
    });

    // 応答を返す
    res.json({
      content: response.content[0].text,
      usage: response.usage,
      model: response.model,
    });
  } catch (error) {
    console.error('Claude API エラー:', error.message);

    if (error.status === 401) {
      return res.status(401).json({ error: 'APIキーが無効です' });
    }
    if (error.status === 429) {
      return res.status(429).json({ error: 'リクエスト制限に達しました。少し待ってから再試行してください' });
    }
    if (error.status === 400) {
      return res.status(400).json({ error: 'リクエスト形式が不正です: ' + error.message });
    }

    res.status(500).json({ error: 'サーバー内部エラーが発生しました' });
  }
});

このエンドポイントは以下のことを行います。

  1. リクエストボディから messages 配列と system プロンプトを取り出す
  2. 入力データのバリデーション(不正なデータを弾く)
  3. Claude API にメッセージを転送
  4. 応答をJSON形式でフロントエンドに返す
  5. エラーが発生した場合は適切なステータスコードとメッセージを返す

CORS の設定を詳細に行う

本番環境では、CORS の設定をより厳密にする必要があります。

> CORS の設定を詳細にして。
  開発環境では全許可、本番環境では特定のオリジンだけ許可する設定にして
// CORS 詳細設定
const corsOptions = {
  origin: process.env.NODE_ENV === 'production'
    ? process.env.ALLOWED_ORIGIN || 'https://yourdomain.com'
    : '*',
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type'],
  maxAge: 86400, // プリフライトリクエストのキャッシュ(24時間)
};

app.use(cors(corsOptions));

開発中は *(全許可)で問題ありませんが、本番環境にデプロイする際は、フロントエンドのURLだけを許可するようにしましょう。これにより、不正なサイトからAPIを呼び出されるリスクを減らせます。

API エンドポイントのテスト

サーバーを起動して、エンドポイントが正しく動作するか確認しましょう。Claude Code にテスト方法を聞きます。

> /api/chat エンドポイントをテストする curl コマンドを書いて。
  日本語で「こんにちは」と送るテスト
# サーバーを起動
npm run dev

# 別のターミナルで curl テスト
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{
    "messages": [
      { "role": "user", "content": "こんにちは!自己紹介してください。" }
    ]
  }'

正常に動作すると、以下のようなJSON応答が返ります。

{
  "content": "こんにちは!私はClaude、Anthropic社が開発したAIアシスタントです...",
  "usage": {
    "input_tokens": 24,
    "output_tokens": 150
  },
  "model": "claude-sonnet-4-20250514"
}

エラーケースもテストしておきましょう。

# 空のメッセージ配列(400エラーが返るはず)
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{ "messages": [] }'

# 不正な形式(400エラーが返るはず)
curl -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{ "messages": [{ "role": "invalid" }] }'

バリデーションが正しく機能していれば、それぞれ適切なエラーメッセージが返されます。

server/index.js の完成形

ここまでの内容をまとめた server/index.js の完成形です。

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const Anthropic = require('@anthropic-ai/sdk');

const app = express();
const PORT = process.env.PORT || 3000;

// Anthropic クライアント
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// ミドルウェア
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, '..', 'public')));

// チャット API
app.post('/api/chat', async (req, res) => {
  try {
    const { messages, system } = req.body;
    if (!messages || !Array.isArray(messages) || messages.length === 0) {
      return res.status(400).json({ error: 'messages が必要です' });
    }
    const response = await anthropic.messages.create({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 2048,
      system: system || 'あなたは親切なアシスタントです。日本語で回答してください。',
      messages,
    });
    res.json({
      content: response.content[0].text,
      usage: response.usage,
    });
  } catch (error) {
    console.error('API Error:', error.message);
    res.status(error.status || 500).json({ error: error.message });
  }
});

app.listen(PORT, () => {
  console.log(`サーバー起動: http://localhost:${PORT}`);
});

演習問題

  1. サーバー起動: npm run dev でサーバーを起動し、http://localhost:3000 にアクセスできることを確認してください。

  2. curl テスト: 上記の curl コマンドを実行して、正常系とエラー系の両方の応答を確認してください。

  3. ヘルスチェックエンドポイント: Claude Code に「GET /api/health でサーバーの状態を返すエンドポイントを追加して」と依頼して、監視用のエンドポイントを作成してください。

  4. リクエストログ: Claude Code に「リクエストの日時、メソッド、パス、レスポンスタイムをコンソールに出力するミドルウェアを追加して」と依頼して、デバッグに役立つログ機能を追加してください。

参考資料

Lecture 4フロントエンド構築 — チャットUIを作る

12:00

フロントエンド構築 — チャットUIを作る

バックエンドが完成したので、次はユーザーが実際に触れるフロントエンドを作ります。LINE や ChatGPT のようなチャットインターフェースを HTML と CSS で構築しましょう。メッセージの吹き出し、入力エリア、自動スクロールなど、使いやすいチャットUIに必要な要素をすべて実装します。Claude Code を使えば、デザインの実装も驚くほどスムーズに進みます。

HTML でチャット画面の構造を作る

まず、Claude Code にチャット画面のHTMLを作ってもらいます。

> public/index.html にチャットボットのUIを作って。
  要件:
  - ヘッダーにアプリ名「Claude Chat」を表示
  - メッセージ表示エリア(スクロール可能)
  - 入力エリア(テキスト入力 + 送信ボタン)
  - レスポンシブ対応
  - CSSとJSは外部ファイルとして読み込む

Claude Code が生成する public/index.html です。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Claude Chat</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <div class="app">
    <!-- ヘッダー -->
    <header class="header">
      <h1 class="header__title">Claude Chat</h1>
      <span class="header__status" id="status">オンライン</span>
    </header>

    <!-- メッセージ表示エリア -->
    <main class="chat-area" id="chatArea">
      <div class="messages" id="messages">
        <!-- ウェルカムメッセージ -->
        <div class="message message--assistant">
          <div class="message__avatar">AI</div>
          <div class="message__content">
            <p>こんにちは!何でも聞いてください。</p>
          </div>
        </div>
      </div>
    </main>

    <!-- 入力エリア -->
    <footer class="input-area">
      <form class="input-form" id="chatForm">
        <textarea
          class="input-form__textarea"
          id="messageInput"
          placeholder="メッセージを入力..."
          rows="1"
          maxlength="4000"
        ></textarea>
        <button
          class="input-form__button"
          type="submit"
          id="sendButton"
          title="送信"
        >
          送信
        </button>
      </form>
    </footer>
  </div>

  <script src="js/app.js"></script>
</body>
</html>

HTML の構造を整理すると、大きく3つのパートに分かれます。

  • header: アプリ名と接続状態の表示
  • main.chat-area: メッセージが表示されるスクロール可能なエリア
  • footer.input-area: テキスト入力欄と送信ボタン

textarea を使っている理由は、複数行のメッセージを入力できるようにするためです。input[type="text"] では1行しか入力できません。

CSS でチャットUIをデザインする

次に、チャットらしい見た目を CSS で実装します。

> public/css/style.css にチャットUIのスタイルを書いて。
  要件:
  - ダークテーマ
  - メッセージは吹き出し形式(ユーザーは右、AIは左)
  - アバターアイコン付き
  - スクロール可能なメッセージエリア
  - 入力エリアは画面下に固定
  - モバイル対応
  - アニメーション(メッセージのフェードイン)

Claude Code が生成する public/css/style.css です。

/* リセット */
*, *::before, *::after {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

:root {
  --color-bg: #1a1a2e;
  --color-bg-secondary: #16213e;
  --color-surface: #0f3460;
  --color-accent: #e94560;
  --color-text: #eee;
  --color-text-muted: #aaa;
  --color-user-bubble: #0f3460;
  --color-assistant-bubble: #16213e;
  --color-input-bg: #16213e;
  --color-border: #333;
  --font-main: 'Segoe UI', 'Noto Sans JP', sans-serif;
}

body {
  font-family: var(--font-main);
  background-color: var(--color-bg);
  color: var(--color-text);
  height: 100vh;
  overflow: hidden;
}

/* アプリコンテナ */
.app {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 800px;
  margin: 0 auto;
}

/* ヘッダー */
.header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  background: var(--color-bg-secondary);
  border-bottom: 1px solid var(--color-border);
}

.header__title {
  font-size: 1.2rem;
  font-weight: 600;
}

.header__status {
  font-size: 0.8rem;
  color: #4ade80;
}

/* チャットエリア */
.chat-area {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  scroll-behavior: smooth;
}

/* メッセージ */
.message {
  display: flex;
  gap: 12px;
  margin-bottom: 16px;
  animation: fadeIn 0.3s ease-in;
}

.message--user {
  flex-direction: row-reverse;
}

.message__avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.75rem;
  font-weight: bold;
  flex-shrink: 0;
  background: var(--color-accent);
}

.message--user .message__avatar {
  background: var(--color-surface);
}

.message__content {
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 16px;
  line-height: 1.6;
  font-size: 0.95rem;
}

.message--assistant .message__content {
  background: var(--color-assistant-bubble);
  border-bottom-left-radius: 4px;
}

.message--user .message__content {
  background: var(--color-user-bubble);
  border-bottom-right-radius: 4px;
}

/* 入力エリア */
.input-area {
  padding: 16px 20px;
  background: var(--color-bg-secondary);
  border-top: 1px solid var(--color-border);
}

.input-form {
  display: flex;
  gap: 12px;
  align-items: flex-end;
}

.input-form__textarea {
  flex: 1;
  padding: 12px 16px;
  background: var(--color-input-bg);
  border: 1px solid var(--color-border);
  border-radius: 24px;
  color: var(--color-text);
  font-size: 0.95rem;
  font-family: var(--font-main);
  resize: none;
  outline: none;
  max-height: 120px;
  line-height: 1.5;
}

.input-form__textarea:focus {
  border-color: var(--color-accent);
}

.input-form__button {
  padding: 12px 24px;
  background: var(--color-accent);
  color: white;
  border: none;
  border-radius: 24px;
  font-size: 0.9rem;
  cursor: pointer;
  transition: background 0.2s;
  white-space: nowrap;
}

.input-form__button:hover {
  background: #d63851;
}

.input-form__button:disabled {
  background: #666;
  cursor: not-allowed;
}

/* ローディング表示 */
.typing-indicator {
  display: flex;
  gap: 4px;
  padding: 8px 0;
}

.typing-indicator span {
  width: 8px;
  height: 8px;
  background: var(--color-text-muted);
  border-radius: 50%;
  animation: bounce 1.4s infinite ease-in-out;
}

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

/* アニメーション */
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}

@keyframes bounce {
  0%, 80%, 100% { transform: scale(0); }
  40% { transform: scale(1); }
}

/* レスポンシブ */
@media (max-width: 600px) {
  .message__content {
    max-width: 85%;
  }
  .header__title {
    font-size: 1rem;
  }
}

メッセージの吹き出しデザインのポイント

チャットUIで最も重要なのが吹き出しのデザインです。ユーザーのメッセージとAIのメッセージを視覚的に区別するために、以下の工夫をしています。

配置の違い: ユーザーのメッセージは右寄せ(flex-direction: row-reverse)、AIのメッセージは左寄せにしています。これはLINEやiMessageなど、多くのチャットアプリで採用されている配置パターンです。

色の違い: ユーザーの吹き出しは濃い青(--color-user-bubble)、AIの吹き出しはやや薄い色(--color-assistant-bubble)にして、視覚的な区別を明確にしています。

角丸の非対称性: 吹き出しの角を一箇所だけ小さくすることで、メッセージの方向(誰が送ったか)を直感的に伝えます。AIの吹き出しは左下、ユーザーの吹き出しは右下の角を小さくしています。

アバター: 小さな丸いアイコンでメッセージの送信者を示します。AIは「AI」、ユーザーは絵文字や頭文字を表示します。

自動スクロールの仕組み

メッセージが増えると画面からはみ出るため、自動スクロール機能が必要です。CSS の scroll-behavior: smooth と JavaScript の組み合わせで実現します。

> チャットエリアに新しいメッセージが追加されたとき、
  自動的に一番下までスムーズスクロールする関数を書いて
function scrollToBottom() {
  const chatArea = document.getElementById('chatArea');
  chatArea.scrollTo({
    top: chatArea.scrollHeight,
    behavior: 'smooth',
  });
}

この関数をメッセージ追加のたびに呼び出すことで、常に最新のメッセージが表示されます。次の講義でJavaScript側のロジックと合わせて実装していきます。

textarea の自動リサイズ

入力欄の textarea は、テキスト量に応じて高さが自動調整されると使いやすくなります。

> textarea の内容に合わせて高さが自動調整される関数を書いて。
  最大4行まで拡大、それ以上はスクロールにする
const textarea = document.getElementById('messageInput');
textarea.addEventListener('input', function () {
  this.style.height = 'auto';
  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});

これにより、1行のメッセージでは小さく、長いメッセージでは最大4行まで入力欄が広がります。120px は約4行分の高さです。

演習問題

  1. HTML/CSS の実装: public/index.htmlpublic/css/style.css を作成し、ブラウザで表示を確認してください。

  2. カラーテーマの変更: Claude Code に「ライトテーマのCSS変数を提案して」と依頼して、ダークテーマとライトテーマを切り替えられるようにしてみましょう。

  3. アバターのカスタマイズ: AIのアバターにロボットのアイコン、ユーザーのアバターに人のアイコンを CSS で表現してみてください。絵文字やSVGを活用しましょう。

  4. ローディングアニメーション: タイピングインジケーター(点が跳ねるアニメーション)がブラウザ上で正しく動くことを確認してください。

参考資料

Lecture 5メッセージ送受信 — フロントとバックエンドを接続する

12:00

メッセージ送受信 — フロントとバックエンドを接続する

HTML/CSS のチャット画面と Express バックエンドが完成しました。この講義では、いよいよ両者を接続して、実際にメッセージを送受信できるようにします。JavaScript の fetch API を使ってフロントエンドからバックエンドにリクエストを送り、Claude の応答を画面に表示します。ローディング状態の管理やエラーハンドリングも実装して、ユーザー体験を向上させましょう。

フロントエンドのJavaScript基本構造

Claude Code にフロントエンドのメインロジックを書いてもらいます。

> public/js/app.js にチャットのメインロジックを書いて。
  要件:
  - フォーム送信でメッセージを送る
  - fetch API で /api/chat にPOSTリクエスト
  - ユーザーメッセージとAI応答を画面に表示
  - 送信中はボタンを無効化してローディング表示
  - Enter キーで送信(Shift+Enter は改行)

Claude Code が生成する public/js/app.js の基本構造です。

// public/js/app.js

// DOM要素の取得
const chatForm = document.getElementById('chatForm');
const messageInput = document.getElementById('messageInput');
const messagesContainer = document.getElementById('messages');
const sendButton = document.getElementById('sendButton');
const chatArea = document.getElementById('chatArea');

// 送信中フラグ
let isLoading = false;

// フォーム送信イベント
chatForm.addEventListener('submit', async (e) => {
  e.preventDefault();
  const text = messageInput.value.trim();
  if (!text || isLoading) return;

  // ユーザーメッセージを表示
  appendMessage('user', text);
  messageInput.value = '';
  messageInput.style.height = 'auto';

  // ローディング開始
  setLoading(true);

  try {
    // バックエンドにリクエスト送信
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages: [{ role: 'user', content: text }],
      }),
    });

    if (!response.ok) {
      const errorData = await response.json();
      throw new Error(errorData.error || 'サーバーエラーが発生しました');
    }

    const data = await response.json();
    appendMessage('assistant', data.content);
  } catch (error) {
    appendMessage('assistant', `エラー: ${error.message}`);
  } finally {
    setLoading(false);
  }
});

// Enter キーで送信(Shift+Enter は改行)
messageInput.addEventListener('keydown', (e) => {
  if (e.key === 'Enter' && !e.shiftKey) {
    e.preventDefault();
    chatForm.dispatchEvent(new Event('submit'));
  }
});

// textarea 自動リサイズ
messageInput.addEventListener('input', function () {
  this.style.height = 'auto';
  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});

このコードのポイントを整理します。

  • e.preventDefault() でフォームのデフォルト送信(ページリロード)を防止
  • trim() で空白だけのメッセージを弾く
  • isLoading フラグで二重送信を防止
  • finally ブロックで、成功・失敗に関わらずローディングを解除

メッセージ表示関数の実装

画面にメッセージを追加する関数を作ります。

> メッセージを吹き出しとして画面に追加する関数を書いて。
  - role('user' or 'assistant')でスタイルを切り替え
  - アバター付き
  - フェードインアニメーション
  - 追加後に自動スクロール
// メッセージを画面に追加
function appendMessage(role, content) {
  const messageDiv = document.createElement('div');
  messageDiv.className = `message message--${role}`;

  const avatarDiv = document.createElement('div');
  avatarDiv.className = 'message__avatar';
  avatarDiv.textContent = role === 'user' ? 'You' : 'AI';

  const contentDiv = document.createElement('div');
  contentDiv.className = 'message__content';

  // テキストを段落に分割して表示
  const paragraphs = content.split('\n').filter(line => line.trim());
  paragraphs.forEach(paragraph => {
    const p = document.createElement('p');
    p.textContent = paragraph;
    p.style.marginBottom = '8px';
    contentDiv.appendChild(p);
  });

  messageDiv.appendChild(avatarDiv);
  messageDiv.appendChild(contentDiv);
  messagesContainer.appendChild(messageDiv);

  scrollToBottom();
}

// 自動スクロール
function scrollToBottom() {
  chatArea.scrollTo({
    top: chatArea.scrollHeight,
    behavior: 'smooth',
  });
}

textContent を使っている理由は、セキュリティのためです。innerHTML を使うと、ユーザーが入力したHTMLタグがそのまま実行されてしまう XSS(クロスサイトスクリプティング)の危険があります。textContent はテキストとしてエスケープされるので安全です。

ローディング状態の管理

メッセージ送信中にローディングインジケーターを表示して、ユーザーに「応答を待っている」ことを伝えます。

> ローディング状態を管理する関数を書いて。
  - 送信ボタンを無効化
  - タイピングインジケーター(点が跳ねるアニメーション)を表示
  - 入力欄をフォーカス
// ローディング状態の切り替え
function setLoading(loading) {
  isLoading = loading;
  sendButton.disabled = loading;

  if (loading) {
    // タイピングインジケーターを追加
    const indicator = document.createElement('div');
    indicator.className = 'message message--assistant';
    indicator.id = 'typingIndicator';

    const avatar = document.createElement('div');
    avatar.className = 'message__avatar';
    avatar.textContent = 'AI';

    const content = document.createElement('div');
    content.className = 'message__content';

    const typing = document.createElement('div');
    typing.className = 'typing-indicator';
    typing.innerHTML = '<span></span><span></span><span></span>';

    content.appendChild(typing);
    indicator.appendChild(avatar);
    indicator.appendChild(content);
    messagesContainer.appendChild(indicator);
    scrollToBottom();
  } else {
    // タイピングインジケーターを削除
    const indicator = document.getElementById('typingIndicator');
    if (indicator) indicator.remove();
    messageInput.focus();
  }
}

タイピングインジケーターは、3つの小さな丸が交互に跳ねるアニメーションです。前の講義で CSS に定義した @keyframes bounce と連動しています。メッセージが返ってきたら、このインジケーターを削除して実際の応答を表示します。

fetch API によるHTTP通信

fetch API を使ったバックエンドとの通信について、より詳しく見てみましょう。

> fetch API のエラーハンドリングのベストプラクティスを教えて。
  ネットワークエラーとHTTPエラーの両方に対応して
async function sendMessage(messages) {
  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ messages }),
    });

    // HTTPステータスコードのチェック
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      if (response.status === 429) {
        throw new Error('リクエスト制限に達しました。少し待ってから再試行してください。');
      }
      if (response.status === 401) {
        throw new Error('認証エラー。APIキーを確認してください。');
      }
      throw new Error(errorData.error || `HTTPエラー: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    // ネットワークエラー(サーバーに到達できない場合)
    if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
      throw new Error('サーバーに接続できません。サーバーが起動しているか確認してください。');
    }
    throw error;
  }
}

fetch API では2種類のエラーを区別する必要があります。

ネットワークエラー: サーバーに到達できない場合に発生します。TypeError: Failed to fetch として捕捉できます。サーバーが起動していない、インターネット接続がないなどの場合に起こります。

HTTPエラー: サーバーには到達したが、エラーレスポンスが返ってきた場合です。fetch API は HTTPエラー(4xx、5xx)でも Promise を resolve するため、response.ok を明示的にチェックする必要があります。

動作確認とデバッグ

すべてのコードを統合して、実際に動作を確認しましょう。

# サーバーを起動
npm run dev

# ブラウザで http://localhost:3000 を開く

正常に動作すると、以下のフローで動きます。

  1. テキストを入力して送信ボタンをクリック(またはEnter)
  2. ユーザーのメッセージが右側の吹き出しで表示される
  3. タイピングインジケーターが表示される
  4. Claude の応答が左側の吹き出しで表示される
  5. 自動的に最新メッセージまでスクロールする

もし動かない場合は、ブラウザの開発者ツール(F12)のコンソールタブでエラーメッセージを確認してください。よくある問題は以下の通りです。

  • サーバーが起動していない(npm run dev を確認)
  • APIキーが設定されていない(.env ファイルを確認)
  • CORS エラー(Express の cors ミドルウェアを確認)

演習問題

  1. 基本動作確認: サーバーを起動してブラウザからメッセージを送り、Claude からの応答が表示されることを確認してください。

  2. エラーテスト: サーバーを停止した状態でメッセージを送信し、エラーメッセージが適切に表示されるか確認してください。

  3. タイムスタンプ追加: Claude Code に「各メッセージに送信時刻を表示する機能を追加して」と依頼して、いつ送ったメッセージかわかるようにしてください。

  4. クリアボタン: Claude Code に「チャット履歴をすべて削除するクリアボタンを追加して」と依頼して、会話をリセットできる機能を追加してください。

参考資料

Lecture 6ストリーミング応答 — リアルタイムで文字を表示する

12:00

ストリーミング応答 — リアルタイムで文字を表示する

ここまでのチャットボットは、Claude が応答を生成し終わるまで待ってから一括で表示していました。これだと長い応答の場合、数秒から十秒以上待たされることがあります。ChatGPT のように文字が1文字ずつ流れるように表示されれば、ユーザー体験は劇的に向上します。この講義では、Server-Sent Events(SSE)とClaude API のストリーミング機能を使って、リアルタイム応答表示を実装します。

ストリーミングの仕組み

通常のHTTPリクエストでは、サーバーはレスポンスを1回で返します。ストリーミングでは、サーバーがレスポンスを少しずつ送り続け、クライアントはそれを受信しながらリアルタイムで画面に表示します。

【通常のリクエスト】
クライアント → リクエスト → サーバー
クライアント ← ────── 応答(一括)← サーバー

【ストリーミング】
クライアント → リクエスト → サーバー
クライアント ← チャンク1 ← サーバー
クライアント ← チャンク2 ← サーバー
クライアント ← チャンク3 ← サーバー
クライアント ← ...      ← サーバー
クライアント ← 完了     ← サーバー

Claude API は stream: true オプションを指定すると、応答をトークン単位で少しずつ返してくれます。これを Server-Sent Events(SSE)形式でフロントエンドに転送することで、リアルタイムなタイピング効果を実現します。

バックエンドにストリーミングエンドポイントを追加する

Claude Code に、ストリーミング対応のエンドポイントを作ってもらいましょう。

> /api/chat/stream というストリーミング対応エンドポイントを server/index.js に追加して
  要件:
  - Claude API の stream: true を使用
  - Server-Sent Events (SSE) 形式でレスポンスを返す
  - テキストチャンクを content_block_delta イベントで検出
  - ストリーム終了時に [DONE] を送信
  - エラーハンドリングも含める

Claude Code が生成するストリーミングエンドポイントです。

// ストリーミングチャット API
app.post('/api/chat/stream', async (req, res) => {
  try {
    const { messages, system } = req.body;

    if (!messages || !Array.isArray(messages) || messages.length === 0) {
      return res.status(400).json({ error: 'messages が必要です' });
    }

    // SSE ヘッダー設定
    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');

    // Claude API にストリーミングリクエスト
    const stream = anthropic.messages.stream({
      model: 'claude-sonnet-4-20250514',
      max_tokens: 2048,
      system: system || 'あなたは親切なアシスタントです。日本語で回答してください。',
      messages: messages,
    });

    // テキストチャンクを受信するたびにクライアントに送信
    stream.on('text', (text) => {
      res.write(`data: ${JSON.stringify({ type: 'text', content: text })}\n\n`);
    });

    // ストリーム完了時
    stream.on('finalMessage', (message) => {
      res.write(`data: ${JSON.stringify({
        type: 'done',
        usage: message.usage,
      })}\n\n`);
      res.end();
    });

    // エラー処理
    stream.on('error', (error) => {
      console.error('ストリームエラー:', error.message);
      res.write(`data: ${JSON.stringify({ type: 'error', message: error.message })}\n\n`);
      res.end();
    });

    // クライアント切断時
    req.on('close', () => {
      stream.abort();
    });

  } catch (error) {
    console.error('ストリーム初期化エラー:', error.message);
    if (!res.headersSent) {
      res.status(500).json({ error: error.message });
    }
  }
});

SSE のデータ形式は data: {JSON}\n\n です。各メッセージは data: で始まり、2つの改行で区切られます。これはSSEの標準プロトコルで、ブラウザの EventSource API や fetch API で自動的にパースできます。

重要なポイントとして、req.on('close') でクライアントが切断した場合にストリームを中止する処理を入れています。ユーザーがページを閉じたり、次のメッセージを送信したりした場合に、不要なAPI呼び出しを止めてコストを節約できます。

フロントエンドでストリーミングを受信する

フロントエンド側で、ストリーミング応答をリアルタイムに受信・表示する処理を実装します。

> public/js/app.js のメッセージ送信処理をストリーミング対応に書き換えて。
  要件:
  - fetch API で /api/chat/stream にPOST
  - ReadableStream でチャンクを受信
  - 受信した文字を逐次的に画面に追加
  - 完了後にローディングを解除
// ストリーミング対応のメッセージ送信
async function sendMessageStream(text) {
  appendMessage('user', text);
  setLoading(true);

  // 空のAI吹き出しを先に作成
  const messageDiv = createEmptyAssistantMessage();
  const contentDiv = messageDiv.querySelector('.message__content');
  let fullContent = '';

  try {
    const response = await fetch('/api/chat/stream', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages: [{ role: 'user', content: text }],
      }),
    });

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

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

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

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue;

        try {
          const data = JSON.parse(line.slice(6));

          if (data.type === 'text') {
            fullContent += data.content;
            contentDiv.textContent = fullContent;
            scrollToBottom();
          } else if (data.type === 'done') {
            console.log('トークン使用量:', data.usage);
          } else if (data.type === 'error') {
            contentDiv.textContent = `エラー: ${data.message}`;
          }
        } catch (e) {
          // JSON パースエラーは無視(不完全なチャンクの場合)
        }
      }
    }
  } catch (error) {
    contentDiv.textContent = `エラー: ${error.message}`;
  } finally {
    setLoading(false);
  }
}

// 空のAI吹き出しを作成
function createEmptyAssistantMessage() {
  // ローディングインジケーターを削除
  const indicator = document.getElementById('typingIndicator');
  if (indicator) indicator.remove();

  const messageDiv = document.createElement('div');
  messageDiv.className = 'message message--assistant';

  const avatar = document.createElement('div');
  avatar.className = 'message__avatar';
  avatar.textContent = 'AI';

  const content = document.createElement('div');
  content.className = 'message__content';

  messageDiv.appendChild(avatar);
  messageDiv.appendChild(content);
  messagesContainer.appendChild(messageDiv);

  return messageDiv;
}

ReadableStreamgetReader() メソッドでチャンクを1つずつ読み取ります。TextDecoder でバイトデータを文字列に変換し、SSE のプロトコルに従って data: で始まる行を解析します。

SSE プロトコルの詳細

Server-Sent Events は、サーバーからクライアントへの一方向通信を実現するWeb標準です。

> SSE (Server-Sent Events) のプロトコル仕様を説明して。
  WebSocket との違いも教えて

SSE の特徴: - HTTPベースで追加のプロトコルが不要 - サーバーからクライアントへの一方向通信 - 自動再接続機能がブラウザに内蔵 - テキストベースでデバッグが容易

WebSocket との比較:

特性 SSE WebSocket
通信方向 一方向(サーバー→クライアント) 双方向
プロトコル HTTP 独自(ws://)
再接続 自動 手動実装が必要
バイナリ 不可 可能
用途 ストリーミング応答、通知 リアルタイムチャット、ゲーム

チャットボットのストリーミング応答には SSE が最適です。Claude API の応答を一方向でストリーミングするだけなので、双方向通信は必要ありません。

フォームイベントハンドラの更新

既存のフォーム送信処理をストリーミング版に切り替えます。

// フォーム送信を更新
chatForm.addEventListener('submit', async (e) => {
  e.preventDefault();
  const text = messageInput.value.trim();
  if (!text || isLoading) return;

  messageInput.value = '';
  messageInput.style.height = 'auto';

  // ストリーミング版で送信
  await sendMessageStream(text);
});

これで、メッセージを送信すると文字が1文字ずつ流れるように表示されるようになります。長い応答でも最初の文字がすぐに表示されるため、体感的な応答速度が大幅に改善されます。

演習問題

  1. ストリーミング実装: バックエンドとフロントエンドの両方にストリーミング処理を実装し、文字がリアルタイムで表示されることを確認してください。

  2. 通常モードとの切り替え: Claude Code に「ストリーミングモードと通常モードを切り替えるトグルスイッチを追加して」と依頼して、両方のモードを比較できるようにしてください。

  3. 応答速度の計測: Claude Code に「応答の最初の文字が表示されるまでの時間(TTFB)と全体の応答時間を表示する機能を追加して」と依頼して、ストリーミングの効果を数値で確認してください。

  4. 中断ボタン: Claude Code に「ストリーミング中に応答を中断するStopボタンを追加して」と依頼して、不要な応答を途中で止められるようにしてください。

参考資料

Lecture 7会話履歴管理 — コンテキストを保持する

12:00

会話履歴管理 — コンテキストを保持する

現在のチャットボットは、毎回のメッセージを独立したリクエストとして送信しています。つまり、Claude は前の会話を覚えていません。「さっき言ったことを詳しく教えて」と聞いても、「さっき」が何だったかわからないのです。この講義では、会話履歴を配列として管理し、Claude API に毎回の会話コンテキストを渡す仕組みを実装します。さらに、localStorage を使ったブラウザ保存で、ページをリロードしても会話が残るようにします。

なぜ会話履歴が必要なのか

Claude API はステートレス(状態を持たない)なAPIです。各リクエストは完全に独立しており、前のリクエストの情報は一切保持されません。会話の文脈を維持するには、クライアント側で会話履歴を管理し、毎回のリクエストに過去の全メッセージを含める必要があります。

【履歴なしの場合】
リクエスト1: "JavaScriptとは何ですか?"     → 回答あり
リクエスト2: "それの使い方を教えて"          → 「それ」が何かわからない

【履歴ありの場合】
リクエスト1: [
  {user: "JavaScriptとは何ですか?"}
]
→ 回答あり

リクエスト2: [
  {user: "JavaScriptとは何ですか?"},
  {assistant: "JavaScriptは...(前の回答)"},
  {user: "それの使い方を教えて"}
]
→ 文脈を理解して回答

会話が長くなるほどリクエストに含めるメッセージ数が増え、トークン消費も増えます。このトレードオフを意識しながら、実用的な履歴管理を実装しましょう。

メッセージ履歴の配列管理

Claude Code に履歴管理のコードを作ってもらいます。

> チャットの会話履歴を管理する仕組みを作って。
  要件:
  - messages 配列でユーザーとAIの全メッセージを保持
  - 新しいメッセージを追加する関数
  - API送信時に全履歴を含める
  - 最大20ターンで古いメッセージを削除

Claude Code が生成する会話履歴管理のコードです。

// public/js/app.js に追加

// 会話履歴
let conversationHistory = [];

// 最大ターン数(ユーザー+AIで1ターン)
const MAX_TURNS = 20;

// 会話履歴にメッセージを追加
function addToHistory(role, content) {
  conversationHistory.push({ role, content });

  // 最大ターン数を超えたら古いメッセージを削除
  // 2メッセージ(user + assistant)で1ターン
  const maxMessages = MAX_TURNS * 2;
  if (conversationHistory.length > maxMessages) {
    // 最も古い1ターン(2メッセージ)を削除
    conversationHistory = conversationHistory.slice(2);
  }
}

// 会話履歴を取得(API送信用)
function getHistory() {
  return [...conversationHistory];
}

// 会話履歴をリセット
function clearHistory() {
  conversationHistory = [];
  saveToLocalStorage();
}

この設計のポイントは、MAX_TURNS で会話の長さを制限している点です。Claude API にはコンテキストウィンドウ(一度に処理できるトークン数)の上限があります。Claude 3.5 Sonnet では 200K トークンが上限ですが、トークン数が増えるほど応答時間と料金が増加します。20ターン(40メッセージ)に制限することで、実用的な範囲に収めています。

ストリーミング送信関数の更新

先ほどのストリーミング送信関数を、会話履歴に対応するよう更新します。

> sendMessageStream 関数を会話履歴対応に書き換えて。
  送信時に全履歴を含め、応答も履歴に追加して
// 会話履歴対応のストリーミング送信
async function sendMessageStream(text) {
  // ユーザーメッセージを履歴に追加
  addToHistory('user', text);
  appendMessage('user', text);
  setLoading(true);

  const messageDiv = createEmptyAssistantMessage();
  const contentDiv = messageDiv.querySelector('.message__content');
  let fullContent = '';

  try {
    const response = await fetch('/api/chat/stream', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages: getHistory(),  // 全履歴を送信
      }),
    });

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

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

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

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        if (!line.startsWith('data: ')) continue;
        try {
          const data = JSON.parse(line.slice(6));
          if (data.type === 'text') {
            fullContent += data.content;
            contentDiv.textContent = fullContent;
            scrollToBottom();
          }
        } catch (e) { /* 不完全なチャンク */ }
      }
    }

    // AI応答を履歴に追加
    addToHistory('assistant', fullContent);
    saveToLocalStorage();

  } catch (error) {
    contentDiv.textContent = `エラー: ${error.message}`;
    // エラー時は最後のユーザーメッセージを履歴から削除
    conversationHistory.pop();
  } finally {
    setLoading(false);
  }
}

重要な変更点は、messagesgetHistory() で全履歴を渡している部分と、応答を受信完了後に addToHistory('assistant', fullContent) で AI の応答を履歴に追加している部分です。エラーが発生した場合は、送信に失敗したユーザーメッセージを履歴から削除して、不整合を防ぎます。

localStorage による永続化

ブラウザを閉じても会話履歴が残るように、localStorage に保存する機能を追加します。

> 会話履歴を localStorage に保存・復元する機能を追加して。
  - ページ読み込み時に復元
  - メッセージ追加時に自動保存
  - 保存容量が上限に近い場合の処理も入れて
const STORAGE_KEY = 'claude-chat-history';

// localStorage に保存
function saveToLocalStorage() {
  try {
    const data = JSON.stringify({
      messages: conversationHistory,
      savedAt: new Date().toISOString(),
    });
    localStorage.setItem(STORAGE_KEY, data);
  } catch (error) {
    if (error.name === 'QuotaExceededError') {
      console.warn('localStorage の容量上限に達しました。古い履歴を削除します。');
      conversationHistory = conversationHistory.slice(
        Math.floor(conversationHistory.length / 2)
      );
      saveToLocalStorage();
    }
  }
}

// localStorage から復元
function loadFromLocalStorage() {
  try {
    const stored = localStorage.getItem(STORAGE_KEY);
    if (!stored) return;

    const data = JSON.parse(stored);
    if (!data.messages || !Array.isArray(data.messages)) return;

    conversationHistory = data.messages;

    // 保存されたメッセージを画面に表示
    conversationHistory.forEach(msg => {
      appendMessage(msg.role, msg.content);
    });

    console.log(`${conversationHistory.length} 件のメッセージを復元しました`);
  } catch (error) {
    console.error('履歴の復元に失敗:', error);
    localStorage.removeItem(STORAGE_KEY);
  }
}

// ページ読み込み時に復元
document.addEventListener('DOMContentLoaded', () => {
  loadFromLocalStorage();
});

localStorage はブラウザごとに約5MBの容量制限があります。QuotaExceededError が発生した場合は、履歴の前半を削除して再保存するフォールバック処理を入れています。

クリアボタンの実装

会話をリセットするボタンもUIに追加しましょう。

> ヘッダーに「新規チャット」ボタンを追加して。
  クリックすると会話履歴をクリアして画面もリセットする

HTMLのヘッダー部分に追加します。

<header class="header">
  <h1 class="header__title">Claude Chat</h1>
  <button class="header__clear-btn" id="clearButton">新規チャット</button>
</header>
.header__clear-btn {
  padding: 6px 16px;
  background: transparent;
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: 16px;
  font-size: 0.8rem;
  cursor: pointer;
  transition: all 0.2s;
}

.header__clear-btn:hover {
  color: var(--color-text);
  border-color: var(--color-text);
}
// クリアボタン
document.getElementById('clearButton').addEventListener('click', () => {
  if (conversationHistory.length === 0) return;
  if (!confirm('会話履歴をすべて削除しますか?')) return;

  clearHistory();
  messagesContainer.innerHTML = '';

  // ウェルカムメッセージを再表示
  appendMessage('assistant', 'こんにちは!何でも聞いてください。');
});

トークン使用量の表示

会話が長くなるとトークン消費も増えます。ユーザーにトークン使用量を可視化してあげましょう。

> 現在の会話のトークン使用量を概算で表示する機能を追加して。
  ヘッダーの下に小さく表示する
// トークン概算(日本語は1文字 ≈ 1-2トークン)
function estimateTokens() {
  const totalChars = conversationHistory.reduce(
    (sum, msg) => sum + msg.content.length, 0
  );
  return Math.ceil(totalChars * 1.5); // 日本語の概算倍率
}

正確なトークン数はAPIの usage レスポンスから取得できますが、送信前の概算として文字数ベースの計算も役立ちます。

演習問題

  1. 会話テスト: 3ターン以上の会話を行い、Claude が前のメッセージの内容を覚えているか確認してください。例えば「私の名前は太郎です」→「私の名前は何ですか?」とテストしてみましょう。

  2. 永続化テスト: メッセージを数件送った後、ページをリロードして会話履歴が復元されるか確認してください。

  3. 履歴上限テスト: Claude Code に「MAX_TURNS を 3 に設定して」と依頼し、古いメッセージが正しく削除されるか確認してください。

  4. エクスポート機能: Claude Code に「会話履歴をJSONファイルとしてダウンロードできるエクスポートボタンを追加して」と依頼して、会話のバックアップ機能を実装してみましょう。

参考資料

Lecture 8システムプロンプト — ボットの性格を設定する

12:00

システムプロンプト — ボットの性格を設定する

Claude API の system パラメータを使うと、ボットの性格、口調、知識の範囲を自由に設定できます。同じ Claude モデルでも、システムプロンプト次第で全く異なるキャラクターのボットを作れるのです。この講義では、効果的なシステムプロンプトの設計方法を学び、複数のペルソナ(性格テンプレート)を切り替えられるUIを実装します。プロンプトエンジニアリングの実践的なスキルも身につけましょう。

システムプロンプトとは

Claude API の messages.create() には system パラメータがあります。これはユーザーには見えない「裏の指示」で、Claude の振る舞いを制御します。

const response = await anthropic.messages.create({
  model: 'claude-sonnet-4-20250514',
  max_tokens: 2048,
  system: 'あなたは親切な日本語教師です。初心者にもわかりやすく丁寧に説明してください。',
  messages: [
    { role: 'user', content: '「食べる」の活用形を教えて' }
  ],
});

システムプロンプトは、すべてのユーザーメッセージよりも前に処理され、会話全体を通じて有効です。ユーザーメッセージに含める指示よりも優先度が高く、一貫した振る舞いを保証します。

効果的なシステムプロンプトの設計

Claude Code にシステムプロンプトの設計を手伝ってもらいましょう。

> チャットボット用のシステムプロンプトを3種類作って。
  1. 親切な汎用アシスタント
  2. プログラミング講師
  3. 英会話練習パートナー
  それぞれ具体的な指示と制約を含めて

Claude Code が生成するシステムプロンプトのテンプレートです。

1. 親切な汎用アシスタント

あなたは「クロード」という名前の親切なAIアシスタントです。

## 基本方針
- 常に丁寧で親しみやすい口調で回答してください
- 質問の意図を正確に理解し、的確に回答してください
- わからないことは正直に「わかりません」と伝えてください
- 回答は簡潔にまとめつつ、必要に応じて詳しく説明してください

## 回答のフォーマット
- 長い説明には見出しや箇条書きを使ってください
- コード例を含む場合はプログラミング言語を明記してください
- 重要なポイントは太字で強調してください

## 制約
- 有害なコンテンツは生成しないでください
- 医療・法律・金融の専門的なアドバイスは避け、専門家への相談を勧めてください

2. プログラミング講師

あなたはプログラミング講師「コード先生」です。

## 教育方針
- 初心者でも理解できるように、専門用語は必ず説明を添えてください
- 具体的なコード例を必ず含めてください
- 「なぜそうするのか」の理由を必ず説明してください
- 一度に多くの概念を詰め込まず、段階的に教えてください

## 回答スタイル
- まず概念を簡単に説明し、次にコード例を示し、最後にポイントをまとめる
- エラーメッセージの読み方も教えてください
- 「やってみましょう」「試してみてください」など、実践を促す言葉を使ってください

## 得意分野
- JavaScript, Python, HTML/CSS が主な対応言語です
- Web開発全般の質問に回答できます

3. 英会話練習パートナー

あなたは英会話の練習パートナー「Alex」です。

## 会話ルール
- ユーザーが英語で話しかけたら英語で返してください
- ユーザーが日本語で話しかけたら、英語の表現を教えてから英語で会話を続けてください
- 文法ミスがあれば、自然な形で正しい表現を教えてください
- 会話のレベルはユーザーに合わせて調整してください

## 教育スタイル
- 間違いを指摘する際は「Great try! A more natural way to say that would be...」のように励ましてから訂正
- 新しい表現を教えるときは例文を2-3個添えてください
- 会話が途切れたら、質問を投げかけて会話を続けてください

## 話題
- 日常会話、旅行、仕事、趣味など幅広いトピックに対応
- ユーザーの興味に合わせて話題を展開してください

バックエンドでシステムプロンプトを処理する

フロントエンドから送られるシステムプロンプトをバックエンドで処理します。

> /api/chat/stream エンドポイントで system パラメータを受け取れるようにして。
  デフォルトのシステムプロンプトも用意して
// server/index.js のストリーミングエンドポイント(更新)
app.post('/api/chat/stream', async (req, res) => {
  const { messages, system } = req.body;

  const defaultSystem = 'あなたは親切で知識豊富なアシスタントです。日本語で回答してください。';

  const stream = anthropic.messages.stream({
    model: 'claude-sonnet-4-20250514',
    max_tokens: 2048,
    system: system || defaultSystem,
    messages: messages,
  });

  // ... ストリーミング処理 ...
});

ペルソナ切替UIの実装

複数のシステムプロンプトを切り替えられるUIを作りましょう。

> ヘッダーにペルソナ選択のドロップダウンメニューを追加して。
  選択肢: 汎用アシスタント、プログラミング講師、英会話パートナー
  選択を変えたら会話をリセットする

HTMLに追加するドロップダウンです。

<header class="header">
  <h1 class="header__title">Claude Chat</h1>
  <div class="header__controls">
    <select class="header__select" id="personaSelect">
      <option value="general">汎用アシスタント</option>
      <option value="coding">プログラミング講師</option>
      <option value="english">英会話パートナー</option>
    </select>
    <button class="header__clear-btn" id="clearButton">新規チャット</button>
  </div>
</header>
.header__controls {
  display: flex;
  gap: 8px;
  align-items: center;
}

.header__select {
  padding: 6px 12px;
  background: var(--color-input-bg);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  border-radius: 8px;
  font-size: 0.8rem;
  cursor: pointer;
  outline: none;
}

.header__select:focus {
  border-color: var(--color-accent);
}

JavaScript でペルソナを管理します。

// ペルソナ定義
const PERSONAS = {
  general: {
    name: '汎用アシスタント',
    system: `あなたは「クロード」という名前の親切なAIアシスタントです。
常に丁寧で親しみやすい口調で回答してください。
わからないことは正直に「わかりません」と伝えてください。
回答は簡潔にまとめつつ、必要に応じて詳しく説明してください。`,
    greeting: 'こんにちは!何でも聞いてください。',
  },
  coding: {
    name: 'プログラミング講師',
    system: `あなたはプログラミング講師「コード先生」です。
初心者でも理解できるように、専門用語は必ず説明を添えてください。
具体的なコード例を必ず含めてください。
「なぜそうするのか」の理由を必ず説明してください。`,
    greeting: 'こんにちは!プログラミングについて何でも聞いてください。コード例を交えて説明しますよ。',
  },
  english: {
    name: '英会話パートナー',
    system: `あなたは英会話の練習パートナー「Alex」です。
ユーザーが英語で話しかけたら英語で返してください。
ユーザーが日本語で話しかけたら、英語の表現を教えてから英語で会話を続けてください。
文法ミスがあれば、自然な形で正しい表現を教えてください。`,
    greeting: 'Hi! I\'m Alex, your English conversation partner. Feel free to talk to me in English or Japanese!',
  },
};

let currentPersona = 'general';

// ペルソナ変更イベント
document.getElementById('personaSelect').addEventListener('change', (e) => {
  const newPersona = e.target.value;
  if (newPersona === currentPersona) return;

  currentPersona = newPersona;
  clearHistory();
  messagesContainer.innerHTML = '';

  // 新しいペルソナのあいさつを表示
  const persona = PERSONAS[currentPersona];
  appendMessage('assistant', persona.greeting);
});

// sendMessageStream のリクエストボディを更新
body: JSON.stringify({
  messages: getHistory(),
  system: PERSONAS[currentPersona].system,
}),

カスタムプロンプトの入力機能

さらに高度な機能として、ユーザーが自分でシステムプロンプトを入力できる機能も追加しましょう。

> ペルソナ選択にカスタムオプションを追加して。
  選択すると、テキストエリアが表示されて
  ユーザーが自分でシステムプロンプトを入力できるようにして
// カスタムペルソナの追加
PERSONAS.custom = {
  name: 'カスタム',
  system: '',
  greeting: 'カスタムペルソナが設定されました。話しかけてください。',
};

// ペルソナ変更時にカスタム入力欄を表示/非表示
document.getElementById('personaSelect').addEventListener('change', (e) => {
  const customArea = document.getElementById('customPromptArea');
  if (e.target.value === 'custom') {
    customArea.style.display = 'block';
  } else {
    customArea.style.display = 'none';
  }
  // ... 既存のペルソナ変更処理 ...
});

これにより、ユーザーは自分だけのオリジナルボットを簡単に作れるようになります。例えば「関西弁で話す料理アドバイザー」や「厳しいけど的確なコードレビュアー」など、用途に合わせたボットを自由に作成できます。

演習問題

  1. ペルソナテスト: 3つのペルソナを切り替えて、それぞれ同じ質問(例:「自己紹介して」)を投げかけ、回答の違いを観察してください。

  2. オリジナルペルソナ作成: Claude Code に「料理アドバイザーのシステムプロンプトを作って」と依頼して、新しいペルソナを追加してください。

  3. プロンプト最適化: Claude Code に「プログラミング講師のシステムプロンプトを改善して。回答にはまず概要、次にコード例、最後に要点まとめのフォーマットを強制して」と依頼して、出力形式をコントロールする方法を学んでください。

  4. ペルソナ永続化: Claude Code に「選択中のペルソナを localStorage に保存して、ページリロード後も同じペルソナが選択されるようにして」と依頼してください。

参考資料

Lecture 9UIの改善 — Markdownレンダリングとコピー機能

12:00

UIの改善 — Markdownレンダリングとコピー機能

チャットボットの基本機能は完成しましたが、現在はClaude の応答がプレーンテキストとして表示されています。Claude は Markdown 形式で応答することが多く、見出し、箇条書き、コードブロックなどがそのままテキストとして表示されてしまいます。この講義では、Markdown をHTMLにレンダリングする機能、コードブロックのシンタックスハイライト、コピーボタン、そしてレスポンシブデザインの改善を実装して、プロフェッショナルなチャット体験を実現します。

marked.js でMarkdownをレンダリングする

Markdown をHTMLに変換するために、軽量なライブラリ marked.js を使います。Claude Code に導入を依頼しましょう。

> marked.js をCDNから読み込んで、Claude の応答をMarkdownとしてレンダリングして。
  要件:
  - CDN から marked.js を読み込む
  - AIの応答のみMarkdownレンダリング(ユーザーのメッセージはプレーンテキスト)
  - XSS対策としてサニタイズする
  - コードブロック、テーブル、リストに対応

まず、HTMLに marked.js と DOMPurify(サニタイズ用)を追加します。

<!-- public/index.html の </body> の前に追加 -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script src="js/app.js"></script>

次に、marked.js の設定を行います。

// public/js/app.js の先頭に追加

// marked.js の設定
marked.setOptions({
  breaks: true,       // 改行を <br> に変換
  gfm: true,          // GitHub Flavored Markdown を有効化
  headerIds: false,    // 見出しに自動IDを付けない
});

// Markdown をHTMLに変換(サニタイズ付き)
function renderMarkdown(text) {
  const html = marked.parse(text);
  return DOMPurify.sanitize(html);
}

DOMPurify.sanitize() は、変換されたHTMLから危険なスクリプトタグやイベントハンドラを除去します。ユーザーが入力したテキストが Claude の応答に含まれる可能性があるため、XSS(クロスサイトスクリプティング)対策として必須です。

appendMessage 関数の更新

メッセージ表示関数を Markdown レンダリングに対応させます。

> appendMessage 関数を更新して、AIの応答はMarkdownレンダリング、
  ユーザーのメッセージはプレーンテキストで表示するようにして
// 更新版 appendMessage
function appendMessage(role, content) {
  const messageDiv = document.createElement('div');
  messageDiv.className = `message message--${role}`;

  const avatarDiv = document.createElement('div');
  avatarDiv.className = 'message__avatar';
  avatarDiv.textContent = role === 'user' ? 'You' : 'AI';

  const contentDiv = document.createElement('div');
  contentDiv.className = 'message__content';

  if (role === 'assistant') {
    // AIの応答はMarkdownレンダリング
    contentDiv.innerHTML = renderMarkdown(content);
  } else {
    // ユーザーのメッセージはプレーンテキスト
    contentDiv.textContent = content;
  }

  messageDiv.appendChild(avatarDiv);
  messageDiv.appendChild(contentDiv);
  messagesContainer.appendChild(messageDiv);

  // コードブロックにコピーボタンを追加
  if (role === 'assistant') {
    addCopyButtons(contentDiv);
  }

  scrollToBottom();
}

ストリーミング表示中は Markdown を逐次レンダリングします。ストリーミング完了後に最終レンダリングを行うことで、パフォーマンスと表示品質のバランスを取ります。

// ストリーミング中のMarkdownレンダリング
// sendMessageStream 関数内を更新

if (data.type === 'text') {
  fullContent += data.content;
  // ストリーミング中もMarkdownレンダリング
  contentDiv.innerHTML = renderMarkdown(fullContent);
  scrollToBottom();
}

コードブロックのシンタックスハイライト

プログラミング関連の会話では、コードブロックが頻繁に登場します。highlight.js を使ってシンタックスハイライトを追加しましょう。

> highlight.js をCDNから読み込んで、コードブロックにシンタックスハイライトを適用して。
  ダークテーマを使って

HTMLに highlight.js を追加します。

<!-- highlight.js のCSS(ダークテーマ) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/atom-one-dark.min.css">
<!-- highlight.js のJS -->
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/highlight.min.js"></script>

marked.js と highlight.js を連携させます。

// marked.js のカスタムレンダラー
const renderer = new marked.Renderer();

renderer.code = function (code, language) {
  const validLanguage = language && hljs.getLanguage(language) ? language : 'plaintext';
  const highlighted = hljs.highlight(code, { language: validLanguage }).value;

  return `<div class="code-block">
    <div class="code-block__header">
      <span class="code-block__language">${validLanguage}</span>
      <button class="code-block__copy" onclick="copyCode(this)">コピー</button>
    </div>
    <pre><code class="hljs language-${validLanguage}">${highlighted}</code></pre>
  </div>`;
};

marked.setOptions({
  renderer: renderer,
  breaks: true,
  gfm: true,
});

コードブロックのスタイルも追加します。

/* コードブロック */
.code-block {
  margin: 12px 0;
  border-radius: 8px;
  overflow: hidden;
  background: #282c34;
}

.code-block__header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 16px;
  background: #21252b;
  font-size: 0.75rem;
}

.code-block__language {
  color: #abb2bf;
  text-transform: uppercase;
}

.code-block__copy {
  padding: 2px 10px;
  background: transparent;
  color: #abb2bf;
  border: 1px solid #abb2bf;
  border-radius: 4px;
  font-size: 0.7rem;
  cursor: pointer;
  transition: all 0.2s;
}

.code-block__copy:hover {
  color: #fff;
  border-color: #fff;
}

.code-block pre {
  margin: 0;
  padding: 16px;
  overflow-x: auto;
}

.code-block code {
  font-family: 'Consolas', 'Monaco', monospace;
  font-size: 0.85rem;
  line-height: 1.6;
}

コピーボタンの実装

コードブロックとメッセージ全体にコピー機能を追加します。

> コードブロックの「コピー」ボタンとメッセージ全体をコピーするボタンを実装して。
  コピー成功時にボタンのテキストを一時的に「コピー済み」に変更して
// コードブロックのコピー
function copyCode(button) {
  const codeBlock = button.closest('.code-block');
  const code = codeBlock.querySelector('code').textContent;

  navigator.clipboard.writeText(code).then(() => {
    const originalText = button.textContent;
    button.textContent = 'コピー済み';
    button.style.color = '#4ade80';
    button.style.borderColor = '#4ade80';

    setTimeout(() => {
      button.textContent = originalText;
      button.style.color = '';
      button.style.borderColor = '';
    }, 2000);
  }).catch(err => {
    console.error('コピー失敗:', err);
  });
}

// メッセージ全体にコピーボタンを追加
function addCopyButtons(contentDiv) {
  // メッセージ全文コピーボタン
  const copyBtn = document.createElement('button');
  copyBtn.className = 'message__copy-btn';
  copyBtn.textContent = 'コピー';
  copyBtn.addEventListener('click', () => {
    const text = contentDiv.textContent;
    navigator.clipboard.writeText(text).then(() => {
      copyBtn.textContent = 'コピー済み';
      setTimeout(() => { copyBtn.textContent = 'コピー'; }, 2000);
    });
  });
  contentDiv.appendChild(copyBtn);
}
/* メッセージコピーボタン */
.message__copy-btn {
  display: block;
  margin-top: 8px;
  padding: 2px 10px;
  background: transparent;
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: 4px;
  font-size: 0.7rem;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.2s;
}

.message:hover .message__copy-btn {
  opacity: 1;
}

.message__copy-btn:hover {
  color: var(--color-text);
  border-color: var(--color-text);
}

メッセージにマウスを乗せたときだけコピーボタンが表示される仕組みにしています。opacity: 0:hover での opacity: 1 の切り替えで、普段は見えないけれど必要なときにアクセスできるUIです。

Markdown表示のスタイル調整

Markdownで生成されるHTML要素のスタイルを調整して、チャットUI内で綺麗に表示されるようにします。

> Markdown由来のHTML要素(h1-h3, ul, ol, table, blockquote, a, p)に
  チャットUI向けのスタイルを追加して
/* Markdown 要素のスタイル */
.message__content h1,
.message__content h2,
.message__content h3 {
  margin: 16px 0 8px;
  font-weight: 600;
}

.message__content h1 { font-size: 1.2rem; }
.message__content h2 { font-size: 1.1rem; }
.message__content h3 { font-size: 1rem; }

.message__content p {
  margin-bottom: 8px;
  line-height: 1.7;
}

.message__content ul,
.message__content ol {
  margin: 8px 0;
  padding-left: 24px;
}

.message__content li {
  margin-bottom: 4px;
}

.message__content blockquote {
  border-left: 3px solid var(--color-accent);
  padding-left: 12px;
  margin: 8px 0;
  color: var(--color-text-muted);
}

.message__content table {
  border-collapse: collapse;
  margin: 8px 0;
  width: 100%;
  font-size: 0.85rem;
}

.message__content th,
.message__content td {
  border: 1px solid var(--color-border);
  padding: 6px 12px;
  text-align: left;
}

.message__content th {
  background: var(--color-bg-secondary);
}

.message__content a {
  color: var(--color-accent);
  text-decoration: none;
}

.message__content a:hover {
  text-decoration: underline;
}

.message__content code:not(.hljs) {
  background: rgba(255, 255, 255, 0.1);
  padding: 2px 6px;
  border-radius: 4px;
  font-family: 'Consolas', monospace;
  font-size: 0.85em;
}

インラインコード(バッククォート1つ)とコードブロック(バッククォート3つ)で異なるスタイルを適用しています。.hljs クラスの有無で区別できます。

演習問題

  1. Markdown テスト: Claude に「Markdown形式でJavaScriptの基本文法を説明して。コードブロック、表、リストを含めて」と依頼して、レンダリング結果を確認してください。

  2. コピー機能テスト: コードブロックのコピーボタンとメッセージ全文のコピーボタンが正しく動作するか確認してください。

  3. テーマ切替: Claude Code に「highlight.js のテーマを GitHub Dark に変更して」と依頼して、別のシンタックスハイライトテーマを試してみてください。

  4. 画像対応: Claude Code に「Markdownの画像記法に対応して、画像をチャット内に表示できるようにして」と依頼して、メディアリッチなチャットUIを目指してください。

参考資料

Lecture 10デプロイと運用 — セキュリティとコスト管理

12:00

デプロイと運用 — セキュリティとコスト管理

チャットボットが完成しました。最終講義では、作ったアプリを実際にインターネット上に公開し、安全に運用するための知識を学びます。APIキーのセキュリティ対策、レート制限によるコスト管理、RailwayやRenderへのデプロイ方法、そして本番環境で注意すべきポイントを網羅します。開発して終わりではなく、安全に運用し続けることがプロの仕事です。

APIキーのセキュリティ対策

本番環境でのAPIキー管理は、最も重要なセキュリティ課題です。Claude Code に包括的なセキュリティ対策を聞いてみましょう。

> チャットボットのAPIキーセキュリティ対策をまとめて。
  フロントエンドからの直接呼び出しのリスク、
  バックエンド経由にする理由、
  環境変数の管理方法を詳しく教えて

絶対にやってはいけないこと:

// 危険!フロントエンドにAPIキーを書いてはいけない
const client = new Anthropic({
  apiKey: 'sk-ant-api03-xxxxxxxx',  // NG: ブラウザから丸見え
});

ブラウザの開発者ツール(F12)で JavaScript コードを確認すれば、フロントエンドに埋め込まれたAPIキーは誰でも見れてしまいます。一度キーが漏洩すると、第三者があなたのクレジットで大量のAPIリクエストを送信し、高額な請求が発生する恐れがあります。

正しいアーキテクチャ:

[ブラウザ] → /api/chat → [Express サーバー] → [Claude API]
                            ↑ APIキーはここだけ

APIキーはサーバーの環境変数にのみ保存し、フロントエンドには一切露出させません。これが今回のアプリで採用しているアーキテクチャです。

追加のセキュリティ施策:

// server/index.js に追加

// 1. APIキーの存在確認(起動時)
if (!process.env.ANTHROPIC_API_KEY) {
  console.error('ANTHROPIC_API_KEY が設定されていません');
  process.exit(1);
}

// 2. Helmet でHTTPヘッダーを強化
const helmet = require('helmet');
app.use(helmet());

// 3. リクエストサイズの制限
app.use(express.json({ limit: '10kb' }));

helmet は、XSS対策やクリックジャッキング防止などのセキュリティヘッダーを自動的に設定するミドルウェアです。

レート制限の実装

APIの使いすぎを防ぐために、レート制限を実装します。

> express-rate-limit を使ってレート制限を実装して。
  要件:
  - 1つのIPアドレスから1分間に10リクエストまで
  - 制限を超えた場合は429ステータスで分かりやすいメッセージ
  - /api/ パスにのみ適用
npm install express-rate-limit
const rateLimit = require('express-rate-limit');

// レート制限設定
const apiLimiter = rateLimit({
  windowMs: 60 * 1000,  // 1分間
  max: 10,               // 最大10リクエスト
  message: {
    error: 'リクエスト制限に達しました。1分後に再試行してください。',
  },
  standardHeaders: true,  // Rate-Limit ヘッダーを返す
  legacyHeaders: false,
});

// /api/ パスにのみ適用
app.use('/api/', apiLimiter);

レート制限はDDoS攻撃の軽減にも効果的です。悪意のあるユーザーが大量のリクエストを送信してAPIコストを膨らませようとしても、レート制限で防げます。

コスト見積もりと管理

Claude API の料金を見積もり、予算内に収める方法を Claude Code に聞きましょう。

> Claude API のコスト見積もりを手伝って。
  - 1日100ユーザー、1ユーザーあたり平均10メッセージ
  - 1メッセージの平均入力500トークン、出力300トークン
  - Claude 3.5 Sonnet の料金で計算して

コスト計算の例:

項目 計算
1日のリクエスト数 100ユーザー x 10メッセージ = 1,000リクエスト
1日の入力トークン 1,000 x 500 = 500,000トークン
1日の出力トークン 1,000 x 300 = 300,000トークン
入力コスト/日 0.5M x $3/1M = $1.50
出力コスト/日 0.3M x $15/1M = $4.50
1日の合計 $6.00(約900円)
月間合計 $180.00(約27,000円)

ただし、会話履歴を含めると実際のトークン数はもっと多くなります。会話が10ターン続くと、10ターン目では過去の全メッセージが入力に含まれるため、入力トークンが大幅に増加します。

コスト管理のための対策:

// server/index.js に追加

// max_tokens を制限
const MAX_OUTPUT_TOKENS = 1024;  // 出力を1024トークンに制限

// 1日の利用上限を設定
let dailyTokenCount = 0;
const DAILY_TOKEN_LIMIT = 500000;

app.post('/api/chat/stream', async (req, res) => {
  if (dailyTokenCount > DAILY_TOKEN_LIMIT) {
    return res.status(429).json({
      error: '本日のAPI利用上限に達しました。明日また来てください。',
    });
  }
  // ... API呼び出し ...
  // 応答後にトークンカウントを更新
  dailyTokenCount += response.usage.input_tokens + response.usage.output_tokens;
});

Anthropic Console の Usage ページでリアルタイムの利用量を確認し、Budget Alerts を設定しておくことも重要です。予算の80%に達したときにメール通知を受け取れます。

Railway へのデプロイ

作成したチャットボットを Railway にデプロイしましょう。Railway は Node.js アプリを簡単にデプロイできるクラウドプラットフォームです。

> Railway にデプロイするための準備をして。
  必要な設定ファイルと手順を教えて

手順 1: package.json の確認

{
  "name": "chatbot-app",
  "version": "1.0.0",
  "scripts": {
    "start": "node server/index.js",
    "dev": "nodemon server/index.js"
  },
  "engines": {
    "node": ">=18.0.0"
  }
}

engines フィールドで Node.js のバージョンを指定します。Railway はこの設定を読み取って適切な環境を構築してくれます。

手順 2: Procfile の作成(オプション)

web: node server/index.js

手順 3: 環境変数を動的ポートに対応

// server/index.js
const PORT = process.env.PORT || 3000;

Railway は PORT 環境変数でポートを割り当てるため、ハードコードせずに環境変数から読み取る必要があります。

手順 4: Railway にデプロイ

# Railway CLI のインストール
npm install -g @railway/cli

# ログイン
railway login

# プロジェクト作成
railway init

# 環境変数設定
railway variables set ANTHROPIC_API_KEY=sk-ant-api03-xxxxx

# デプロイ
railway up

デプロイが完了すると、https://chatbot-app-xxxx.railway.app のようなURLが発行されます。

Render へのデプロイ(代替案)

Railway の代わりに Render を使うこともできます。Render は無料プランが充実しています。

> Render にデプロイする方法を教えて。
  GitHub リポジトリ連携でデプロイする方法で

手順 1: GitHubにリポジトリをプッシュ

git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/your-username/chatbot-app.git
git push -u origin main

手順 2: Render でサービス作成 1. render.com にアクセスしてアカウント作成 2. 「New Web Service」をクリック 3. GitHubリポジトリを選択 4. 設定を入力: - Build Command: npm install - Start Command: node server/index.js - Environment Variables: ANTHROPIC_API_KEY を設定

手順 3: 自動デプロイ設定

Render は GitHub リポジトリの main ブランチに push するたびに自動デプロイします。コードを更新して push するだけで、最新版が自動的に反映されます。

本番環境のチェックリスト

デプロイ前に確認すべき項目をリスト化しました。

> 本番デプロイ前のチェックリストを作って。
  セキュリティ、パフォーマンス、エラーハンドリングの観点で

セキュリティ: - [ ] APIキーが .env にのみ保存されているか - [ ] .gitignore.env が含まれているか - [ ] CORS が本番ドメインに制限されているか - [ ] レート制限が設定されているか - [ ] リクエストサイズの制限があるか - [ ] Helmet でHTTPヘッダーが強化されているか

パフォーマンス: - [ ] max_tokens が適切に制限されているか - [ ] 会話履歴の長さに上限があるか - [ ] 静的ファイルが適切にキャッシュされているか

エラーハンドリング: - [ ] APIエラーがユーザーに適切に表示されるか - [ ] ネットワークエラーのハンドリングがあるか - [ ] サーバーエラーのログが出力されているか

運用: - [ ] Anthropic Console で Budget Alerts が設定されているか - [ ] 日次のトークン使用量ログがあるか - [ ] サーバーのヘルスチェックエンドポイントがあるか

演習問題

  1. セキュリティ監査: Claude Code に「このプロジェクトのセキュリティリスクを洗い出して」と依頼して、追加の対策が必要な箇所を特定してください。

  2. レート制限テスト: レート制限を1分間に3リクエストに設定し、連続でメッセージを送信して制限が正しく動作するか確認してください。

  3. デプロイ実践: Railway または Render にデプロイして、スマートフォンからもアクセスできることを確認してください。

  4. コスト計算: Claude Code に「自分の利用パターン(1日のユーザー数、メッセージ数)を元に月額コストを計算して」と依頼して、自分のアプリの運用コストを見積もってみましょう。

参考資料