iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

PMに「Hono書いて」と言われたのでCloudflareでAIチャットボットを作ってみた

◼️ はじめに

株式会社iimonでエンジニアをしている「あめちゃん」です!
本記事はiimon Advent Calendar 2025の15日目の記事となります!

12月は社内のエンジニアでアドベントカレンダーに記事を投稿するという一大イベントが発生してる最中です。
僕も何か良い題材がないかなぁと色々考えていました。
そんな中、とある朝の打ち合わせでアドベントカレンダーの話題が出た時にPM陣の方からこんなお言葉をいただきました!


「だれかHonoとかbunとか書いてくれないですか?」
「Hono!!!」

せっかく書いて欲しいという要望があるなら書こう!!と思い今回はHonoについて調べてみました!!!(と書きつつCloudflareメインになってしまいました...!)
またHonoを使うならCloudflareのサービスを使って何かしらアプリを作成したいと思います。

個人的にもHonoは最近よく聞くなと感じてはいたものの、よくある「Hello World」を出力したことしかなかったため、 今回はアプリを作ることで本来の良さを感じることができればと思います!

通常、エンジニア向けの記事であればパフォーマンス計測やベンチマーク比較でメリット・デメリットを提示すると思いますが、 今回はPMの方からのリクエストということもあり、「どんなサービスが作れるのか」を実際に動くものを通して紹介したいと思います。

◼️ Honoとは

Honoとは、Web標準に基づいて構築された小型でシンプルそして超高速なWebフレームワークです。
Web標準のみを使用しているので、Web標準をサポートするあらゆるランタイムで実行できます。 (Node.jsをはじめとした、DenoやBun、AWSのLambdaでも実行可能)

◼️ Cloudflareとは

Cloudflare(クラウドフレア)とは、ウェブサイトやアプリケーションの表示速度を高速化しセキュリティを強化するためのグローバルネットワークサービスです。
CloudflareのサービスにはエッジでJavaScriptやTypeScriptを実行させるWorkersや、SQLiteをベースに構築されたD1などたくさんのサービスが存在します。

今回はこのHonoとCloudflareの各サービスを用いて旅行のアクティビティお勧めAIチャットボットのアプリケーションを開発していきたいと思います。

◼️ 今回作成するアプリのアーキテクチャに関して


※ 今回はデモ用途のため、認証などの処理は省略しています。

各サービスの役割

技術 用途 補足
Hono Webフレームワーク ルーティングとJSXによるHTML生成を担当。
HTMX フロントエンド JS書かずにAjax通信を実現。
ユーザとチャットボットが会話するための画面を担当。
D1 RDB 商品の構造化データ(アクティビティ名、エリア、カテゴリ、料金、説明)を保存。
Vectorize ベクトルDB 自然言語での類似検索。
アクティビティ情報をembedding化して保存し「静かな場所で開催」のような曖昧な自然言語での類似検索を実現。
Workers KV セッション管理 会話履歴を保存。
sessionIdをキーにチャット履歴を24時間TTLで保存。
Workers AI LLM・Embedding 条件抽出・応答生成・ベクトル化 。

◼️ プロジェクト作成

※ 本記事ではCloudflareのアカウント登録が完了している前提で進めます。

Honoを使用するためのプロジェクト作成

$ npm create hono@latest activity-chat
# 選択肢
# - cloudflare-workers を選択
# - TypeScript を選択

各リソースを作成

$ cd activity-chat

# D1データベース作成
$ npx wrangler d1 create activity-db

# Vectorizeインデックス作成
$ npx wrangler vectorize create activity-index --dimensions=1024 --metric=cosine

# Workers KV作成(会話履歴保存用)
$ npx wrangler kv namespace create ACTIVITY_CHAT_KV

※ Workers AIは事前のリソース作成不要で、wrangler.jsoncにバインディングを追加するだけで利用できます。

各リソースを作成する際にWranglerに追加するかみたいなことを聞かれるので、yesを指定すると自動設定されるので楽です。
今回は試すだけなのでリモートリソースに接続するようにしてます。
(VectorizeやWorkers AIはローカルエミュレートできないためリモート接続が必要です)

ちなみに、リソース作成まで完了すると下記の様な wrangler.jsonc が出来上がっているはずです。

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "activity-chat",
  "main": "src/index.ts",
  "compatibility_date": "2025-12-11",
  "d1_databases": [
    {
      "binding": "activity_db",
      "database_name": "activity-db",
      "database_id": "", // 対象のID
      "remote": true
    }
  ],
  "vectorize": [
    {
      "binding": "VECTORIZE",
      "index_name": "activity-index",
      "remote": true
    }
  ],
  "kv_namespaces": [
    {
      "binding": "ACTIVITY_CHAT_KV",
      "id": "",  // 対象のID
      "remote": true
    }
  ],
  "ai": { // Workers AIを使用するために記述
    "binding": "AI"
  }
}

型定義のインストール

CloudflareサービスのTypeScript型を使うために、型定義パッケージをインストールする必要があります。

npm install -D @cloudflare/workers-types
{
  "compilerOptions": {
    :
    :
    "types": ["@cloudflare/workers-types"]  // 追加
  }
}

D1テーブル作成

D1はCloudflareが提供するSQLiteベースのデータベースです。
従来のRDBと同じようにSQLが使え、Cloudflareのエッジで動作するため高速です。

先ほど作成したD1データベースに、テーブルを定義していきましょう。 今回は軽く試すだけなので、 アクティビティ名エリアカテゴリ料金詳細内容 のカラムを持つシンプルな構成にします。

CREATE TABLE IF NOT EXISTS activities (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,       -- アクティビティ名
  area TEXT NOT NULL,    -- エリア
  category TEXT NOT NULL,    -- カテゴリ
  price INTEGER NOT NULL,     -- 料金
  detail TEXT NOT NULL,     -- 詳細内容
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

スキーマが定義できたら下記コマンドで実際にテーブル作成します。

$ npx wrangler d1 execute activity-db --remote --file=./schema.sql

◼️ 実装

  • 今回は後続対応でHTMXを用いるため、 src/index.tssrc/index.tsx にリネームしておきます。
  • wrangler.jsonc の設定も "main": "src/index.tsx", に変えておきましょう。
  • src/index.tsx に全て記載していく想定で作成します。
  • 最初に記載されてる内容は削除して構いません。

Bindings型定義

Cloudflareの各サービスにアクセスするための型を定義します。

  • src/index.tsx
import { Hono } from "hono";

type Bindings = {
  activity_db: D1Database;
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  ACTIVITY_CHAT_KV: KVNamespace;
};

const app = new Hono<{ Bindings: Bindings }>();

export default app;

アクティビティ登録APIを作成

先にアクティビティデータをD1に登録するためのAPIを作成します。 アクティビティデータをD1に登録し、同時にVectorizeにもembeddingを保存するようにします。

D1に登録したデータはエリアやカテゴリでの絞り込み等に使用し、Vectorizeは自然言語での類似検索に使用します。

Embeddingとは
テキストを数値ベクトル(今回の場合1024次元の配列)に変換する技術です。 例えば「子供と一緒に楽しめるアクティビティ」と「ファミリー向け初心者OK」は、 文字列としては異なりますが、ベクトル空間では近い位置に配置されます。 これにより、キーワード一致ではなく「意味的な類似度」で検索することができます。

このEmbeddingを保存・検索するためにVectorizeを使用します。

Vectorizeとは
Cloudflareが提供するベクトルデータベースです。 Embeddingで生成した数値ベクトルを保存し、類似度検索ができます。 今回はアクティビティ情報をベクトル化して保存し、「静かな場所で開催」のような 曖昧な検索条件にも対応できるようにしています。

// アクティビティ登録
app.post("/api/activities", async (c) => {
  const { name, area, category, price, detail } = await c.req.json();
  const id = crypto.randomUUID();

  // D1に保存
  await c.env.activity_db
    .prepare(
      "INSERT INTO activities (id, name, area, category, price, detail) VALUES (?, ?, ?, ?, ?, ?)"
    )
    .bind(id, name, area, category, price, detail)
    .run();

  // Vectorizeにembeddingを保存
  const text = `${name} ${area} ${category} ${detail} ${price}円`;
  const embedding = (await c.env.AI.run("@cf/baai/bge-m3", {
    text: [text],
  })) as { data: number[][] };

  await c.env.VECTORIZE.upsert([
    {
      id,
      values: embedding.data[0],
      metadata: { name, area, category, price, detail },
    },
  ]);

  return c.json({ id, success: true });
});

export default app;

ベクトル検索についてはこちらの記事にわかりやすく説明が載っています!

テストデータ登録

npm run dev でサーバを立ち上げて下記のcurlコマンドを実行してデータを登録します。 (データはサンプルです。)

curl -X POST http://localhost:8787/api/activities \
  -H "Content-Type: application/json" \
  -d '{"name": "渓流ラフティング体験", "area": "群馬県みなかみ", "category": "ラフティング", "price": 7500, "detail": "利根川上流で初心者でも楽しめる爽快ラフティング。ガイド付きで安心。"}'

チャットAPI作成

セッション初期化

Workers KVにセッション作成、挨拶メッセージを返却するAPIを作成していきます。

Workers KV(Key-Value Store)とは
Cloudflareが提供するシンプルなキーバリューストアです。 今回はsessionIdをキーに会話履歴を保存しています。 RDBと違いスキーマ定義が不要で、JSONをそのまま保存できるため、 セッション管理やキャッシュに適しています。 TTL(有効期限)を設定できるので、24時間後に自動削除されます。

ポイント

  • sessionId: UUIDで一意のセッションIDを生成
  • expirationTtl: 60 * 60 * 24: 24時間で自動削除
  • hx-swap-oob="true": HTMXのOut-of-Band swap。レスポンス内の要素を、ターゲット外の場所(hidden input)にも反映させる

上記にも記載してますが、今回Workers KVを使用する目的としてはLLMに会話履歴を渡すためです。
ユーザーが「東京で」「〇〇円以下で」「激しい内容がいい」と複数回に分けて条件を伝えた場合、過去の会話を覚えていないと検索条件を正しく抽出できません。
Workers KVにsessionIdをキーにして会話履歴を保存し毎回LLMに渡すことで、文脈を理解した応答が可能になります。

// セッション初期化
app.get("/chat/init", async (c) => {
  const sessionId = crypto.randomUUID();
  const greeting =
    "こんにちは!アクティビティ探しのお手伝いをします 🎿\n\nどんな体験をお探しですか?エリアや予算、やりたいこと(ラフティング、ダイビングなど)を教えてください!";

  await c.env.ACTIVITY_CHAT_KV.put(
    sessionId,
    JSON.stringify([{ role: "assistant", content: greeting }]),
    { expirationTtl: 60 * 60 * 24 }
  );

  return c.html(
    <>
      <input
        type="hidden"
        name="sessionId"
        id="sessionId"
        value={sessionId}
        hx-swap-oob="true"
      />
      <div class="message assistant">{greeting}</div>
    </>
  );
});

メッセージ送信

ユーザーからのメッセージを受け取り、アクティビティ検索と応答生成を行うAPIです。
チャットのメイン処理で以下のフローで動作します。

  1. Workers KVから会話履歴を取得
  2. Workers AI(LLM)で検索条件を抽出 (@cf/meta/llama-3.1-8b-instruct)
  3. アクティビティ検索
    • D1: アクティビティ名・エリア・カテゴリ・料金・詳細内容で絞り込み
    • Vectorize: D1で見つからない場合、Embeddingで類似検索にフォールバック
  4. Workers AI(LLM)で応答文を生成 (@cf/meta/llama-3.1-8b-instruct)
  5. Workers KVに会話履歴を保存

Workers AI とは
Cloudflareのグローバルネットワーク上で動作するサーバーレスAI推論サービスです。 GPUインフラを意識することなく、1つのAIバインディングで複数のモデルを使い分けられます。 今回は以下の2種類を使用しています: - llama-3.1-8b-instruct: 条件抽出・応答生成(LLM) - @cf/baai/bge-m3: テキストのベクトル化(Embedding)

コードを表示する(長いので折りたたんでます!)

// チャットメッセージ送信処理
app.post("/chat/send", async (c) => {
  const body = await c.req.parseBody();
  const sessionId = (body.sessionId as string) || crypto.randomUUID();
  const message = body.message as string;

  if (!message) {
    return c.html(
      <div class="message assistant">メッセージを入力してください。</div>
    );
  }

  // 会話履歴取得
  const historyJson = sessionId
    ? await c.env.ACTIVITY_CHAT_KV.get(sessionId)
    : null;
  const history: { role: string; content: string }[] = historyJson
    ? JSON.parse(historyJson)
    : [];

  history.push({ role: "user", content: message });

  // Step 1: 条件抽出
  const extractResponse = (await c.env.AI.run(
    "@cf/meta/llama-3.1-8b-instruct" as any,
    {
      messages: [
        {
          role: "system",
          content: `ユーザーの発言からアクティビティ検索の条件を抽出してJSON形式で出力してください。

## 出力形式(必ずこの形式のJSONのみを出力)
{"search": boolean, "maxPrice": number|null, "area": string|null, "category": string|null, "query": string|null, "missing": string|null}

## ルール
- search: area と category の両方が判明している場合は true
- missing: search が false の場合、不足情報を日本語で記載

## 例
入力: "東京で5000円くらいのアウトドア体験を探しています"
出力: {"search": true, "maxPrice": 5000, "area": "東京", "category": "アウトドア", "query": "東京 アウトドア体験", "missing": null}

入力: "何か楽しいことしたい"
出力: {"search": false, "maxPrice": null, "area": null, "category": null, "query": null, "missing": "エリアとやりたいことの種類を教えてください"}

入力: "沖縄でダイビングしたい"
出力: {"search": true, "maxPrice": null, "area": "沖縄", "category": "ダイビング", "query": "沖縄 ダイビング", "missing": null}`,
        },
        {
          role: "user",
          content: history.map((h) => `${h.role}: ${h.content}`).join("\n"),
        },
      ],
      temperature: 0,
      max_tokens: 200,
    }
  )) as { response?: string };

  let botMessage = "";
  let activities: any[] = [];

  try {
    // JSONを抽出
    const jsonMatch = extractResponse.response?.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      const conditions = JSON.parse(jsonMatch[0]);

      if (conditions.search && conditions.query) {
        // Step 2: アクティビティ検索
        // D1で条件検索
        let query = "SELECT * FROM activities WHERE 1=1";
        const params: any[] = [];

        if (conditions.maxPrice) {
          query += " AND price <= ?";
          params.push(conditions.maxPrice);
        }
        if (conditions.area) {
          query += " AND area LIKE ?";
          params.push(`%${conditions.area}%`);
        }
        if (conditions.category) {
          query += " AND category LIKE ?";
          params.push(`%${conditions.category}%`);
        }
        query += " LIMIT 10";

        const stmt = c.env.activity_db.prepare(query);
        const { results } =
          params.length > 0
            ? await stmt.bind(...params).all()
            : await stmt.all();

        // Vectorizeで類似検索
        if (results && results.length > 0) {
          activities = results as any[];
        } else {
          // D1で見つからない場合はVectorizeで検索
          const embedding = (await c.env.AI.run("@cf/baai/bge-m3", {
            text: [conditions.query],
          })) as { data: number[][] };
          const vectorResults = await c.env.VECTORIZE.query(embedding.data[0], {
            topK: 5,
            returnMetadata: "all",
          });
          activities = vectorResults.matches.map((m) => m.metadata);
        }

        // Step 3: 結果を元に応答生成
        if (activities.length > 0) {
          const activityList = activities
            .map(
              (a: any, i: number) =>
                `${i + 1}. ${a.name} - ${a.area} - ${
                  a.category
                } - ${a.price?.toLocaleString()}円`
            )
            .join("\n");

          const recommendResponse = (await c.env.AI.run(
            "@cf/meta/llama-3.1-8b-instruct" as any,
            {
              messages: [
                {
                  role: "system",
                  content:
                    "アクティビティアドバイザーとして、見つかった体験をユーザーにおすすめしてください。簡潔に、各アクティビティの魅力を添えて紹介してください。",
                },
                {
                  role: "user",
                  content: `ユーザーの条件: ${conditions.query}\n\n見つかったアクティビティ:\n${activityList}`,
                },
              ],
            }
          )) as { response?: string };
          botMessage = recommendResponse.response || "";
        } else {
          botMessage =
            "ご希望の条件に合うアクティビティが見つかりませんでした。条件を変えて再度お試しください。";
        }
      }
    }
  } catch (e) {
    console.error("Parse error:", e);
  }

  // 条件抽出できなかった場合は通常の会話
  if (!botMessage) {
    const response = (await c.env.AI.run(
      "@cf/meta/llama-3.1-8b-instruct" as any,
      {
        messages: [
          {
            role: "system",
            content: `あなたは親切なアクティビティアドバイザーですユーザーの希望条件エリア予算やりたいこと人数などを聞き出してください日本語で簡潔に回答してください`,
          },
          ...history.map((h) => ({
            role: h.role as "user" | "assistant",
            content: h.content,
          })),
        ],
      }
    )) as { response?: string };
    botMessage =
      response.response || "すみません、うまく応答できませんでした。";
  }

  history.push({ role: "assistant", content: botMessage });

  // 履歴保存
  await c.env.ACTIVITY_CHAT_KV.put(sessionId, JSON.stringify(history), {
    expirationTtl: 60 * 60 * 24,
  });

  // レスポンス
  return c.html(
    <>
      <div class="message user">{message}</div>
      <div class="message assistant">
        {botMessage}
        {activities.length > 0 && (
          <div style="margin-top: 12px;">
            {activities.map((a: any) => (
              <div class="activity-card">
                <strong>{a.name}</strong>
                <br />
                📍 {a.area}
                <br />
                🏷️ {a.category} / 💰 {a.price?.toLocaleString()}円
              </div>
            ))}
          </div>
        )}
      </div>
    </>
  );
});

チャットUI

HTMXでAPIを呼び出す画面を作成します。

今回はできるだけシンプルな構造でアプリを作成したかったのでHTMXを用いる形にしてます。

HTMXの主要属性について軽く説明

属性 説明
hx-get / hx-post リクエスト先URL
hx-trigger="load" ページ読み込み時に自動実行
hx-target レスポンスを挿入する要素
hx-swap="beforeend" 既存の末尾に追加
hx-disabled-elt リクエスト中に無効化する要素
hx-on::before-request リクエスト前に実行するJS
hx-on::after-request リクエスト後に実行するJS
app.get("/", (c) => {
  return c.html(
    <html>
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>アクティビティチャットボット</title>
        <script src="https://unpkg.com/htmx.org@2.0.4"></script>
        <style>{`
          * { box-sizing: border-box; margin: 0; padding: 0; }
          body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f5f5f5; }
          .container { max-width: 600px; margin: 0 auto; padding: 20px; }
          h1 { margin-bottom: 20px; }
          .chat-box { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden; }
          .messages { height: 500px; overflow-y: auto; padding: 16px; }
          .message { margin: 12px 0; padding: 12px 16px; border-radius: 12px; max-width: 80%; white-space: pre-wrap; }
          .user { background: #007bff; color: white; margin-left: auto; }
          .assistant { background: #e9ecef; }
          .input-area { display: flex; gap: 8px; padding: 16px; border-top: 1px solid #eee; }
          .input-area input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
          .input-area button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
          .input-area button:hover { background: #0056b3; }
          .input-area button:disabled { background: #6c757d; cursor: not-allowed; }
          .activity-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 12px; margin-top: 8px; }
          .activity-card strong { color: #007bff; }
          .loading { color: #6c757d; font-style: italic; }
        `}</style>
      </head>
      <body>
        <div class="container">
          <h1>🎿 アクティビティ探しチャット</h1>
          <div class="chat-box">
            <div
              id="messages"
              class="messages"
              hx-get="/chat/init"
              hx-trigger="load"
              hx-swap="innerHTML"
            ></div>
            <form
              class="input-area"
              hx-post="/chat/send"
              hx-target="#messages"
              hx-swap="beforeend"
              hx-disabled-elt="#submit-btn"
              {...{
                "hx-on::before-request":
                  "document.getElementById('submit-btn').textContent = '送信中...'",
                "hx-on::after-request":
                  "document.getElementById('submit-btn').textContent = '送信'; this.reset(); document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight",
              }}
            >
              <input type="hidden" name="sessionId" id="sessionId" />
              <input
                type="text"
                name="message"
                placeholder="やりたいことを入力..."
                autocomplete="off"
                required
              />
              <button type="submit" id="submit-btn">
                送信
              </button>
            </form>
          </div>
        </div>
      </body>
    </html>
  );
});

◼️ 動作確認

なんとかアクティビティお勧めAIチャットボットが完成しました。
データはAIツールで適当なデータを数百件作成してもらいD1とVectorizeに保存してます。 ※ デプロイはしてません🙅‍♂️

コード全体(src/index.tsx)

import { Hono } from "hono";

type Bindings = {
  activity_db: D1Database;
  VECTORIZE: VectorizeIndex;
  AI: Ai;
  ACTIVITY_CHAT_KV: KVNamespace;
};

const app = new Hono<{ Bindings: Bindings }>();

// ホームページ
app.get("/", (c) => {
  return c.html(
    <html>
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>アクティビティチャットボット</title>
        <script src="https://unpkg.com/htmx.org@2.0.4"></script>
        <style>{`
          * { box-sizing: border-box; margin: 0; padding: 0; }
          body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f5f5f5; }
          .container { max-width: 600px; margin: 0 auto; padding: 20px; }
          h1 { margin-bottom: 20px; }
          .chat-box { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden; }
          .messages { height: 500px; overflow-y: auto; padding: 16px; }
          .message { margin: 12px 0; padding: 12px 16px; border-radius: 12px; max-width: 80%; white-space: pre-wrap; }
          .user { background: #007bff; color: white; margin-left: auto; }
          .assistant { background: #e9ecef; }
          .input-area { display: flex; gap: 8px; padding: 16px; border-top: 1px solid #eee; }
          .input-area input { flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; }
          .input-area button { padding: 12px 24px; background: #007bff; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 16px; }
          .input-area button:hover { background: #0056b3; }
          .input-area button:disabled { background: #6c757d; cursor: not-allowed; }
          .activity-card { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px; padding: 12px; margin-top: 8px; }
          .activity-card strong { color: #007bff; }
          .loading { color: #6c757d; font-style: italic; }
        `}</style>
      </head>
      <body>
        <div class="container">
          <h1>🎿 アクティビティ探しチャット</h1>
          <div class="chat-box">
            <div
              id="messages"
              class="messages"
              hx-get="/chat/init"
              hx-trigger="load"
              hx-swap="innerHTML"
            ></div>
            <form
              class="input-area"
              hx-post="/chat/send"
              hx-target="#messages"
              hx-swap="beforeend"
              hx-disabled-elt="#submit-btn"
              {...{
                "hx-on::before-request":
                  "document.getElementById('submit-btn').textContent = '送信中...'",
                "hx-on::after-request":
                  "document.getElementById('submit-btn').textContent = '送信'; this.reset(); document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight",
              }}
            >
              <input type="hidden" name="sessionId" id="sessionId" />
              <input
                type="text"
                name="message"
                placeholder="やりたいことを入力..."
                autocomplete="off"
                required
              />
              <button type="submit" id="submit-btn">
                送信
              </button>
            </form>
          </div>
        </div>
      </body>
    </html>
  );
});

// チャットメッセージ送信処理
app.post("/chat/send", async (c) => {
  const body = await c.req.parseBody();
  const sessionId = (body.sessionId as string) || crypto.randomUUID();
  const message = body.message as string;

  if (!message) {
    return c.html(
      <div class="message assistant">メッセージを入力してください。</div>
    );
  }

  // 会話履歴取得
  const historyJson = sessionId
    ? await c.env.ACTIVITY_CHAT_KV.get(sessionId)
    : null;
  const history: { role: string; content: string }[] = historyJson
    ? JSON.parse(historyJson)
    : [];

  history.push({ role: "user", content: message });

  // Step 1: 条件抽出
  const extractResponse = (await c.env.AI.run(
    "@cf/meta/llama-3.1-8b-instruct" as any,
    {
      messages: [
        {
          role: "system",
          content: `ユーザーの発言からアクティビティ検索の条件を抽出してJSON形式で出力してください。

## 出力形式(必ずこの形式のJSONのみを出力)
{"search": boolean, "maxPrice": number|null, "area": string|null, "category": string|null, "query": string|null, "missing": string|null}

## ルール
- search: area と category の両方が判明している場合は true
- missing: search が false の場合、不足情報を日本語で記載

## 例
入力: "東京で5000円くらいのアウトドア体験を探しています"
出力: {"search": true, "maxPrice": 5000, "area": "東京", "category": "アウトドア", "query": "東京 アウトドア体験", "missing": null}

入力: "何か楽しいことしたい"
出力: {"search": false, "maxPrice": null, "area": null, "category": null, "query": null, "missing": "エリアとやりたいことの種類を教えてください"}

入力: "沖縄でダイビングしたい"
出力: {"search": true, "maxPrice": null, "area": "沖縄", "category": "ダイビング", "query": "沖縄 ダイビング", "missing": null}`,
        },
        {
          role: "user",
          content: history.map((h) => `${h.role}: ${h.content}`).join("\n"),
        },
      ],
      temperature: 0,
      max_tokens: 200,
    }
  )) as { response?: string };

  let botMessage = "";
  let activities: any[] = [];

  try {
    // JSONを抽出
    const jsonMatch = extractResponse.response?.match(/\{[\s\S]*\}/);
    if (jsonMatch) {
      const conditions = JSON.parse(jsonMatch[0]);

      if (conditions.search && conditions.query) {
        // Step 2: アクティビティ検索
        // D1で条件検索
        let query = "SELECT * FROM activities WHERE 1=1";
        const params: any[] = [];

        if (conditions.maxPrice) {
          query += " AND price <= ?";
          params.push(conditions.maxPrice);
        }
        if (conditions.area) {
          query += " AND area LIKE ?";
          params.push(`%${conditions.area}%`);
        }
        if (conditions.category) {
          query += " AND category LIKE ?";
          params.push(`%${conditions.category}%`);
        }
        query += " LIMIT 10";

        const stmt = c.env.activity_db.prepare(query);
        const { results } =
          params.length > 0
            ? await stmt.bind(...params).all()
            : await stmt.all();

        // Vectorizeで類似検索
        if (results && results.length > 0) {
          activities = results as any[];
        } else {
          // D1で見つからない場合はVectorizeで検索
          const embedding = (await c.env.AI.run("@cf/baai/bge-m3", {
            text: [conditions.query],
          })) as { data: number[][] };
          const vectorResults = await c.env.VECTORIZE.query(embedding.data[0], {
            topK: 5,
            returnMetadata: "all",
          });
          activities = vectorResults.matches.map((m) => m.metadata);
        }

        // Step 3: 結果を元に応答生成
        if (activities.length > 0) {
          const activityList = activities
            .map(
              (a: any, i: number) =>
                `${i + 1}. ${a.name} - ${a.area} - ${
                  a.category
                } - ${a.price?.toLocaleString()}円`
            )
            .join("\n");

          const recommendResponse = (await c.env.AI.run(
            "@cf/meta/llama-3.1-8b-instruct" as any,
            {
              messages: [
                {
                  role: "system",
                  content:
                    "アクティビティアドバイザーとして、見つかった体験をユーザーにおすすめしてください。簡潔に、各アクティビティの魅力を添えて紹介してください。",
                },
                {
                  role: "user",
                  content: `ユーザーの条件: ${conditions.query}\n\n見つかったアクティビティ:\n${activityList}`,
                },
              ],
            }
          )) as { response?: string };
          botMessage = recommendResponse.response || "";
        } else {
          botMessage =
            "ご希望の条件に合うアクティビティが見つかりませんでした。条件を変えて再度お試しください。";
        }
      }
    }
  } catch (e) {
    console.error("Parse error:", e);
  }

  // 条件抽出できなかった場合は通常の会話
  if (!botMessage) {
    const response = (await c.env.AI.run(
      "@cf/meta/llama-3.1-8b-instruct" as any,
      {
        messages: [
          {
            role: "system",
            content: `あなたは親切なアクティビティアドバイザーですユーザーの希望条件エリア予算やりたいこと人数などを聞き出してください日本語で簡潔に回答してください`,
          },
          ...history.map((h) => ({
            role: h.role as "user" | "assistant",
            content: h.content,
          })),
        ],
      }
    )) as { response?: string };
    botMessage =
      response.response || "すみません、うまく応答できませんでした。";
  }

  history.push({ role: "assistant", content: botMessage });

  // 履歴保存
  await c.env.ACTIVITY_CHAT_KV.put(sessionId, JSON.stringify(history), {
    expirationTtl: 60 * 60 * 24,
  });

  // レスポンス
  return c.html(
    <>
      <div class="message user">{message}</div>
      <div class="message assistant">
        {botMessage}
        {activities.length > 0 && (
          <div style="margin-top: 12px;">
            {activities.map((a: any) => (
              <div class="activity-card">
                <strong>{a.name}</strong>
                <br />
                📍 {a.area}
                <br />
                🏷️ {a.category} / 💰 {a.price?.toLocaleString()}円
              </div>
            ))}
          </div>
        )}
      </div>
    </>
  );
});

// セッション初期化
app.get("/chat/init", async (c) => {
  const sessionId = crypto.randomUUID();
  const greeting =
    "こんにちは!アクティビティ探しのお手伝いをします 🎿\n\nどんな体験をお探しですか?エリアや予算、やりたいこと(ラフティング、ダイビングなど)を教えてください!";

  await c.env.ACTIVITY_CHAT_KV.put(
    sessionId,
    JSON.stringify([{ role: "assistant", content: greeting }]),
    { expirationTtl: 60 * 60 * 24 }
  );

  return c.html(
    <>
      <input
        type="hidden"
        name="sessionId"
        id="sessionId"
        value={sessionId}
        hx-swap-oob="true"
      />
      <div class="message assistant">{greeting}</div>
    </>
  );
});

// アクティビティ登録
app.post("/api/activities", async (c) => {
  const { name, area, category, price, detail } = await c.req.json();
  const id = crypto.randomUUID();

  // D1に保存
  await c.env.activity_db
    .prepare(
      "INSERT INTO activities (id, name, area, category, price, detail) VALUES (?, ?, ?, ?, ?, ?)"
    )
    .bind(id, name, area, category, price, detail)
    .run();

  // Vectorizeにembeddingを保存
  const text = `${name} ${area} ${category} ${detail} ${price}円`;
  const embedding = (await c.env.AI.run("@cf/baai/bge-m3", {
    text: [text],
  })) as { data: number[][] };

  await c.env.VECTORIZE.upsert([
    {
      id,
      values: embedding.data[0],
      metadata: { name, area, category, price, detail },
    },
  ]);

  return c.json({ id, success: true });
});

export default app;

今後の改善箇所

正直現在の状態だと、お勧めしてくれるアクティビティ情報の精度は低いです。 そのため、D1やVectorizeのデータの持たせ方や使用するAIのモデル指定等をチューニングして精度を上げる必要があります。

あとUIも適当に表示させてるだけなので、ローディングやAIが考えてるようなUIにしてユーザ体験を高めていくような改善は必要そうです。

◼️ まとめ

今回はHonoとCloudflareを用いてAIチャットボットを作ってみました。
ちなみにコード自体はClaude Codeを用いることで、何もわからない状態から30分〜1時間くらいで作成できました。
(AIチャットボットを作るにあたりベクトル検索をしたいということはAIに伝えてる)
近年のAIツールには驚きを隠せません。。。
「とりあえず動くもの」を作るハードルが劇的に下がっているので、アイデアを素早く形にしたい方にはAIアシスタントの活用を強くお勧めします。

また、Cloudflareのサービスにも驚かされました。
今回使用したD1、Vectorize、Workers AI、Workers KVはすべて無料枠で利用できます。AWSで同様の構成を組もうとすると、RDS(またはDynamoDB)、OpenSearch、Bedrock、ElastiCacheなど複数のサービスを組み合わせる必要があり、無料枠だけで収めるのは難しいでしょう。
Cloudflareならこれらが1つのエコシステムで完結し、しかも無料で試せるのは大きなメリットです。

あとCloudflareのリモートサービスに接続する際にenv情報を必要としないところも開発体験として良かったです!

これからは素早くプロトタイプを作っていろんな検証ができる時代になっていくなと感じさせられました。

■最後に

ここまで記事を読んでいただきありがとうございました!

弊社ではエンジニアを募集しております。
ご興味がありましたらカジュアル面談も可能ですので、下記リンクより是非ご応募ください!
iimon採用サイト / Wantedly

明日のアドベントカレンダー担当は「須藤さん」です!
SREチームでいろんなタスクをこなしている「須藤さん」がどんな記事を書いてくれるか楽しみですね!!

◼️ 参考

Firestoreのベクトル検索で「Qiita意味検索エンジン」を作ろう (Hono x Genkit x Firestore)
Hono
Cloudflare
D1