🐶

Cloudflare Workers AI × Hono × htmx で作るAIチャット【サンプルあり】

に公開

はじめに

OpenAI APIやGemini APIは便利ですが、コストが気になることがあります。個人開発や社内ツールで「もっと安くAI機能を使いたい」、「バズった場合青天井で課金されるのが怖い」と思ったことはないでしょうか?

Cloudflare Workers AIは、Cloudflareのエッジで動くAI推論サービスです。Llama 3やMistralなどのオープンソースモデルを、従量課金で安価に利用できます。

この記事では、Workers AI × Hono × htmx を使って、シンプルなAIチャットアプリを作ります。フロントエンドからバックエンドまで1プロジェクトで完結し、最後にCloudflareへデプロイするところまで解説します。

作るもの

  • シンプルなチャットUI(htmx + Tailwind CSS)
  • AIとの会話API(Hono + Workers AI)
  • レスポンス対応

前提条件

  • Node.js 18以上
  • Cloudflareアカウント(無料プランでOK)
  • 基本的なTypeScript/Honoの知識

Workers AI とは

Workers AIは、CloudflareのエッジネットワークでAI推論を実行できるサービスです。

特徴

  • 低レイテンシ:ユーザーに近いエッジで推論
  • 従量課金:使った分だけ支払い(Neurons単位)
  • 無料枠あり:毎日10,000 Neurons(約1,000リクエスト相当)
  • 豊富なモデル:テキスト生成、埋め込み、画像生成など

利用可能なモデル(一部)

カテゴリ モデル例
テキスト生成 @cf/meta/llama-3.1-8b-instruct, @cf/mistral/mistral-7b-instruct-v0.1
埋め込み @cf/baai/bge-base-en-v1.5
画像生成 @cf/stabilityai/stable-diffusion-xl-base-1.0
音声認識 @cf/openai/whisper

今回はテキスト生成に @cf/meta/llama-3.1-8b-instruct を使用します。

料金比較

1,000リクエスト(各500トークン出力)あたりの概算:

サービス 料金
OpenAI GPT-4o-mini 約$0.30
Workers AI Llama 3.1 8B 約$0.01〜0.02

プロジェクトのセットアップ

Cloudflare Pages + Hono + Vite の構成で作成します。

1. プロジェクト作成

npm create hono@latest workers-ai-chat

対話形式で以下を選択:

? Which template do you want to use? cloudflare-pages
? Do you want to install project dependencies? yes
? Which package manager do you want to use? npm

2. ディレクトリ移動と追加パッケージのインストール

cd workers-ai-chat

# htmx と Tailwind CSS をインストール
npm install htmx.org
npm install -D tailwindcss postcss autoprefixer @cloudflare/workers-types

3. ファイル構成

以下の構成で作成していきます。ルーティング、ビューを分離した構成です:

workers-ai-chat/
├── src/
│   ├── index.tsx              # エントリーポイント(ルートのマウントのみ)
│   ├── types.ts               # 型定義
│   ├── routes/
│   │   ├── index.ts           # ルートのエクスポート
│   │   ├── home.tsx           # トップページ
│   │   └── chat.tsx           # チャットAPI
│   ├── views/
│   │   ├── layout.tsx         # 共通レイアウト
│   │   └── chat-page.tsx      # チャットページUI
│   ├── lib/
│   └── styles/
│       └── globals.css        # Tailwind CSS
├── public/
│   └── static/
│       └── htmx.min.js        # htmxのコピー
├── wrangler.toml
├── tailwind.config.js
├── postcss.config.js
├── vite.config.ts
└── package.json

設定ファイルの作成

wrangler.toml

wrangler.toml
name = "workers-ai-chat"
compatibility_date = "2024-01-01"
pages_build_output_dir = "./dist"

[ai]
binding = "AI"

tailwind.config.js

tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
}

postcss.config.js

tailwind.config.js
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

vite.config.ts

vite.config.ts
import build from '@hono/vite-build/cloudflare-pages'
import devServer from '@hono/vite-dev-server'
import adapter from '@hono/vite-dev-server/cloudflare'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    build(),
    devServer({
      adapter,
      entry: 'src/index.tsx',
    }),
  ],
  css: {
    postcss: './postcss.config.js',
  },
}
)

tsconfig.json(@cloudflare/workers-typesを追加する)

tsconfig.json
"types": ["vite/client", "@cloudflare/workers-types"],  

package.json のscripts確認

以下のスクリプトがあることを確認(なければ追加):

package.json
{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "wrangler pages dev",
    "deploy": "npm run build && wrangler pages deploy"
  }
}

htmx のセットアップ

node_modulesからhtmxをpublicディレクトリにコピーします。

1. vite-plugin-static-copy のインストール

npm install -D vite-plugin-static-copy

2. vite.config.ts を更新する

vite.config.ts
import build from '@hono/vite-build/cloudflare-pages'
import devServer from '@hono/vite-dev-server'
import adapter from '@hono/vite-dev-server/cloudflare'
import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'

export default defineConfig({
  plugins: [
    build(),
    devServer({
      adapter,
      entry: 'src/index.tsx',
    }),
    viteStaticCopy({
      targets: [
        {
          src: 'node_modules/htmx.org/dist/htmx.min.js',
          dest: 'static',
        },
      ],
    }),
  ],
  css: {
    postcss: './postcss.config.js',
  },
})

3. publicディレクトリの作成

mkdir -p public/static

CSSファイルの作成

src/styles/globals.css

src/styles/globals.css
@import "tailwindcss";

.chat-bubble {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
max-width: 20rem;
}

.chat-bubble-user {
background-color: #3b82f6;
color: white;
}

.chat-bubble-ai {
background-color: #e5e7eb;
color: #1f2937;
}

アプリケーションの実装

ファイルを分割して実装していきます。

src/types.ts

アプリ全体で使用する型定義:

src/types.ts
export type Bindings = {
  AI: Ai
}

export type HonoEnv = {
  Bindings: Bindings
}

src/views/layout.tsx

共通のHTMLレイアウト:

src/views/layout.tsx
import type { FC, PropsWithChildren } from 'hono/jsx'

type LayoutProps = PropsWithChildren<{
  title?: string
}>

export const Layout: FC<LayoutProps> = ({ children, title = 'Workers AI Chat' }) => {
  return (
    <html lang="ja">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>{title}</title>
        <link rel="stylesheet" href="/static/styles.css" />
        <script src="/static/htmx.min.js"></script>
      </head>
      <body class="bg-gray-100 min-h-screen">
        {children}
      </body>
    </html>
  )
}

src/views/chat-page.tsx

チャットページのUI:

src/views/chat-page.tsx
import type { FC } from 'hono/jsx'
import { Layout } from './layout'

export const ChatPage: FC = () => {
  return (
    <Layout title="Workers AI Chat">
      <div class="container mx-auto max-w-2xl p-4">
        <h1 class="text-2xl font-bold text-center mb-6">Workers AI Chat</h1>
        
        {/* チャット履歴 */}
        <div 
          id="chat-history" 
          class="bg-white rounded-lg shadow p-4 mb-4 h-96 overflow-y-auto"
        >
          <p class="text-gray-500 text-center">メッセージを入力してください</p>
        </div>
        
        {/* 入力フォーム */}
        <form
          hx-post="/api/chat"
          hx-target="#chat-history"
          hx-swap="beforeend"
          hx-on--after-request="this.reset(); document.getElementById('chat-history').scrollTop = document.getElementById('chat-history').scrollHeight;"
          class="flex gap-2"
        >
          <input
            type="text"
            name="message"
            placeholder="メッセージを入力..."
            required
            autocomplete="off"
            class="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button
            type="submit"
            class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition"
          >
            送信
          </button>
        </form>
      </div>
    </Layout>
  )
}

src/routes/home.tsx

トップページのルート:

src/routes/home.tsx
import { Hono } from 'hono'
import type { HonoEnv } from '../types'
import { ChatPage } from '../views/chat-page'

const app = new Hono<HonoEnv>()

app.get('/', (c) => {
  return c.html(<ChatPage />)
})

export default app

src/routes/chat.tsx

チャットAPIのルート:

src/routes/chat.tsx
import { Hono } from 'hono'
import type { HonoEnv } from '../types'

const app = new Hono<HonoEnv>()

app.post('/', async (c) => {
  const { message } = await c.req.parseBody()

  if (typeof message !== 'string' || !message.trim()) {
    return c.html(<div class="text-red-500">メッセージを入力してください</div>)
  }

  try {
    // Workers AI でテキスト生成(非ストリーミング)
    const result = await c.env.AI.run(
      '@cf/meta/llama-3.1-8b-instruct' as keyof AiModels,
      {
        messages: [
          { role: 'user', content: message }
        ],
      }
    )

    const response = (result as { response?: string }).response ?? 'エラーが発生しました'

    return c.html(
      <div>
        <div class="mb-4">
          <div class="flex justify-end">
            <div class="chat-bubble chat-bubble-user">
              {message}
            </div>
          </div>
        </div>
        <div class="mb-4">
          <div class="flex justify-start">
            <div class="chat-bubble chat-bubble-ai">
              {response}
            </div>
          </div>
        </div>
      </div>
    )
  } catch (error) {
    console.error('AI Error:', error)
    return c.html(
      <div>
        <div class="mb-4">
          <div class="flex justify-end">
            <div class="chat-bubble chat-bubble-user">
              {message}
            </div>
          </div>
        </div>
        <div class="mb-4">
          <div class="flex justify-start">
            <div class="chat-bubble chat-bubble-ai text-red-500">
              エラーが発生しました
            </div>
          </div>
        </div>
      </div>
    )
  }
})

export default app

src/routes/index.ts

ルートの集約とエクスポート:

src/routes/index.ts
export { default as homeRoutes } from './home'
export { default as chatRoutes } from './chat'

src/index.tsx

エントリーポイント(ルートのマウントのみ):

src/index.tsx
import { Hono } from 'hono'
import type { HonoEnv } from './types'
import { homeRoutes, chatRoutes } from './routes'

const app = new Hono<HonoEnv>()

// エラーハンドリング
app.onError((err, c) => {
  console.error('Error:', err)
  return c.html(
    <div class="text-red-500 p-4">
      エラーが発生しました。しばらく経ってから再度お試しください。
    </div>,
    500
  )
})

// ルートのマウント
app.route('/', homeRoutes)
app.route('/api/chat', chatRoutes)

export default app

ファイル分割のポイント

ディレクトリ 役割
routes/ HTTPエンドポイントの定義
views/ HTMLコンポーネント(表示のみ)
lib/ 共通ユーティリティ
types.ts 型定義

CSSのビルド設定

Tailwind CSSをビルドして静的ファイルとして配信します。

package.json にビルドスクリプトを追加

package.json
{
  "scripts": {
    "dev": "npm run build:css && vite",
    "build": "npm run build:css && vite build",
    "build:css": "npx tailwindcss -i ./src/styles/globals.css -o ./public/static/styles.css --minify", 
    "preview": "wrangler pages dev",
    "deploy": "npm run build && wrangler pages deploy"
  }
}

CSSをビルド

npm run build:css

これで public/static/styles.css が生成されます。

ローカルで動作確認

1. Cloudflareにログイン(初回のみ)

npx wrangler login

2. wrangler.jsoncに下記を追記(preview環境で実行するため)

wrangler.jsonc
"ai": {
"binding": "AI"
}

3. 実行する

npm run build && npm run preview

ブラウザで http://localhost:8788/ を開きます。

動作確認

  1. メッセージを入力して「送信」をクリック
  2. ユーザーのメッセージが右側に表示される
  3. AIの応答が左側に表示される

Cloudflare Pagesへデプロイ

1. ビルドとデプロイ

npm run deploy

初回は以下の確認が出ます:

? Would you like to create a new project? enter
? What would you like to set as the production branch? production

2. 動作確認

表示されたURLにアクセスして動作を確認:

https://workers-ai-chat.pages.dev

本番運用のTips

エラーハンドリング

src/index.tsx には既にグローバルなエラーハンドリングを実装しています。各ルートで個別のエラー処理が必要な場合は、ルートファイル内で追加できます:

src/routes/chat.tsx
// 特定のエラーに対する処理を追加する場合
app.post('/', async (c) => {
  try {
    // ... 処理
  } catch (error) {
    if (error instanceof SomeSpecificError) {
      return c.html(<div class="text-yellow-500">特定のエラーが発生しました</div>, 400)
    }
    throw error // グローバルハンドラーに委譲
  }
})

コスト監視

Cloudflareダッシュボードで以下を確認できます:

  • Workers & Pages > 対象プロジェクト > Analytics
  • AI > Overview(Workers AI の使用量)

無料枠を超えそうな場合はアラートを設定しておくと安心です。

まとめ

この記事では、Cloudflare Workers AI × Hono × htmx を使って、AIチャットアプリを作成しました。

今回のポイント

  • Workers AI で安価にAI機能を実装
  • Hono でTypeScript製の軽量バックエンド
  • htmx でJavaScript最小限のインタラクティブUI
  • Tailwind CSS をビルドして静的配信
  • Cloudflare Pages でフロントエンドとAPIを一括デプロイ

メリット・デメリット

メリット デメリット
低コスト(無料枠あり) GPT-4oなどと比べると精度は劣る
低レイテンシ 日本語の品質はモデルによる
シンプルな構成 大規模利用には追加設計が必要
デプロイが簡単 モデルの選択肢は限られる

向いているユースケース

  • 個人開発のAI機能
  • 社内ツール
  • プロトタイピング
  • コストを抑えたい小規模サービス

次のステップ

  • D1(Cloudflareのsqliteデータベース)と連携して会話履歴を保存(ローカルでも実行可能)
  • 複数のモデルを切り替えられるようにする
  • 認証機能の追加

サンプルリポジトリ

参考リンク


質問やフィードバックがあれば、コメントでお知らせください!

Discussion