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
name = "workers-ai-chat"
compatibility_date = "2024-01-01"
pages_build_output_dir = "./dist"
[ai]
binding = "AI"
tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
}
postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
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を追加する)
"types": ["vite/client", "@cloudflare/workers-types"],
package.json のscripts確認
以下のスクリプトがあることを確認(なければ追加):
{
"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 を更新する
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
@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
アプリ全体で使用する型定義:
export type Bindings = {
AI: Ai
}
export type HonoEnv = {
Bindings: Bindings
}
src/views/layout.tsx
共通のHTMLレイアウト:
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:
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
トップページのルート:
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のルート:
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
ルートの集約とエクスポート:
export { default as homeRoutes } from './home'
export { default as chatRoutes } from './chat'
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 にビルドスクリプトを追加
{
"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環境で実行するため)
"ai": {
"binding": "AI"
}
3. 実行する
npm run build && npm run preview
ブラウザで http://localhost:8788/ を開きます。
動作確認
- メッセージを入力して「送信」をクリック
- ユーザーのメッセージが右側に表示される
- 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 には既にグローバルなエラーハンドリングを実装しています。各ルートで個別のエラー処理が必要な場合は、ルートファイル内で追加できます:
// 特定のエラーに対する処理を追加する場合
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