1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Databricks Appsでhtmx+FastAPIを利用したアプリを作る

Last updated at Posted at 2025-12-21

はじめに

こちらの記事を以前読みまして。

私もプロトタイプ作成にStreamlitを使うことが多いのですが、特に本番環境という面でかなり頷く内容も多く。
また、htmxを不勉強ながら知らなかったこともあり、Databricks Appsで使えるのか?&使用感を確認してみました。

検証はDatabricks on AWSで行っています。

なお、htmxの公式サイトはこちら。

実施した内容

簡単なAIチャットボットアプリをHTMX + FastAPIの組み合わせで作成し、Databricks Appsへデプロイする。

結論①:Databricks Apps上で動く?

動きました。(そりゃそうですよね)

結論②:htmxの所感は?

もともとWebアプリのデザインはHTML+CSSで行う方が好きなこともあり、個人的にはいい感じ。
非常にコンパクトなフレームワークのため、便利なコンポーネントなどが豊富にあるわけではありませんが、それらは他で補えばいいですし、サーバサイド側でHTMLを生成する必要性がある部分もJinja2テンプレートの利用で問題ないと感じています。
(とはいえそこまで複雑なことをしていないので、実用的なものを作る場合には他に課題ありそうですが)

検証用のチャットアプリを作ってみる

それでは、実際にDatabricks App上でhtmxを利用したアプリを作ってデプロイします。

まずはDatabricks Appsでhtmx-sampleという空のプロジェクトを作ります。
このとき、「アプリのリソース」として適当なサービングポイントを利用できるようにしておきます。
(今回はdatabricks-gpt-oss-20bを選びました)

image.png

必要なファイル類を準備します。

まず、必須のapp.yaml
コマンドとしては、FastAPIで定義したバックエンドをuvicornから起動します。
環境変数SESSION_SECRET_KEYはFastAPIのセッション管理で利用するシークレットキーです。(適切に変更してください)

app.yaml
command: ["uvicorn", "backend.main:app"]

env:
  - name: "DATABRICKS_MODEL_ENDPOINT"
    valueFrom: "serving-endpoint"
  - name: "SESSION_SECRET_KEY"
    value: "secret_xxx"

次にrequirements.txt
(一部必須ではないものも含まれています)

requirements.txt
fastapi==0.124.4
uvicorn==0.38.0
python-multipart==0.0.9
aiofiles==25.1.0
jinja2==3.1.6
databricks-connect==15.4.*
databricks-sdk==0.74.0
openai==2.11.0
python-dotenv==1.2.1
mlflow==3.7.0
itsdangerous==2.2.0
starlette==0.42.0

ここからがアプリに関する内容です。

まず、templatesというフォルダを作成し、その中にJinja2テンプレートとして利用するhtmlファイルを作成していきます。
(デザイン含めて大半Claude Codeを利用して作成しました。)

まず、ルートとなるindex.html

templates/index.html
{% import "message_macros.html" as macros %}
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTMX Chat Assistant</title>
    <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"></script>
    <script src="https://cdn.tailwindcss.com"></script>
</head>

<body class="bg-gray-50 h-screen flex flex-col">

    <!-- ヘッダー -->
    <header class="bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
        <h1 class="text-xl font-bold text-gray-800">HTMX Chat</h1>
        <button hx-post="/clear-history" hx-confirm="会話履歴をクリアしますか?"
            hx-on::after-request="if(event.detail.successful) window.location.reload()"
            class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition text-sm">
            新しいチャット
        </button>
    </header>

    <!-- メインエリア -->
    <main class="flex-1 overflow-y-auto bg-white">
        <div class="max-w-4xl mx-auto px-4 py-6 space-y-4" id="chat-feed">

            {% if not conversation_history %}
                <!-- 初回メッセージ -->
                {{ macros.assistant_message_display("何を知りたいですか?") }}
            {% endif %}

            {% for message in conversation_history %}
                {% if message.role == "user" %}
                    {{ macros.user_message_display(message.content) }}
                {% elif message.role == "assistant" %}
                    {{ macros.assistant_message_display(message.content) }}
                {% endif %}
            {% endfor %}
        </div>
    </main>

    <!-- 入力エリア -->
    <div class="border-t border-gray-200 bg-white p-4">
        <div class="max-w-4xl mx-auto">
            <form id="chat-form" hx-post="/user-message" hx-target="#chat-feed" hx-swap="beforeend scroll:main:bottom"
                hx-disabled-elt="#send-button"
                hx-on::after-request="this.reset(); document.getElementById('message-input').focus();"
                class="flex gap-2">
                <textarea id="message-input" name="message" rows="1" placeholder="メッセージを入力..."
                    class="flex-1 border border-gray-300 rounded-lg px-4 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
                    onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();const btn=document.getElementById('send-button');if(!btn.disabled)this.form.requestSubmit();}"
                    required></textarea>
                <button type="submit" id="send-button"
                    class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-400">
                    送信
                </button>
            </form>
        </div>
    </div>
</body>

</html>

各要素に含まれているhx-xxxという属性がhtmxの特徴です。
JavaScriptを書くのではなく、この属性を加えることでAjaxを使ったバックエンドとの非同期通信やその結果によるDOMの入替ができたりします。
Java Server Facesをなぜか思い出す。

その他のテンプレートも定義します。

templates/user_message.html
{% import "message_macros.html" as macros %}

{{ macros.user_message_display(user_message) }}

{{ macros.ai_loading_indicator() }}

<!-- Trigger AI Response (hidden form) -->
<form hx-post="/ai-response"
      hx-trigger="load"
      hx-target="#ai-loading"
      hx-swap="outerHTML scroll:main:bottom"
      hx-disabled-elt="#send-button"
      hx-on::after-swap="document.getElementById('message-input').focus();"
      style="display:none;">
    <input type="hidden" name="message" value="{{ user_message }}">
</form>
templates/assistant_message.html
{% import "message_macros.html" as macros %}

{{ macros.assistant_message_display(assistant_message) }}

これらはチャットの入力やAIの回答時に利用されます。
特にuser_message.htmlのテンプレートの中で、さらにhtmxを使った処理を実行することでサービングエンドポイントへの通信を開始していたりします。

最後にこれらのテンプレート共通で使うマクロ。
主にメッセージ表示部分の共通化に利用しています。

message_macros.html
{% macro user_message_display(message) %}
<!-- User Message -->
<div class="flex gap-3 justify-end">
    <div class="flex-1 flex justify-end">
        <div class="bg-blue-500 text-white rounded-lg px-4 py-3 max-w-2xl">
            <p class="whitespace-pre-wrap">{{ message }}</p>
        </div>
    </div>
    <div class="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center flex-shrink-0">
        <span class="text-gray-700 text-sm font-bold">U</span>
    </div>
</div>
{% endmacro %}

{% macro assistant_message_display(message) %}
<!-- AI Message -->
<div class="flex gap-3">
    <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
        <span class="text-white text-sm font-bold">AI</span>
    </div>
    <div class="flex-1">
        <div class="bg-gray-100 rounded-lg px-4 py-3 max-w-2xl">
            <p class="text-gray-800 whitespace-pre-wrap">{{ message }}</p>
        </div>
    </div>
</div>
{% endmacro %}

{% macro ai_loading_indicator() %}
<!-- AI Thinking Indicator -->
<div class="flex gap-3" id="ai-loading">
    <div class="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center flex-shrink-0">
        <span class="text-white text-sm font-bold">AI</span>
    </div>
    <div class="flex-1">
        <div class="bg-gray-100 rounded-lg px-4 py-3 max-w-2xl">
            <div class="flex items-center gap-2">
                <div class="flex gap-1">
                    <span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 0ms;"></span>
                    <span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 150ms;"></span>
                    <span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay: 300ms;"></span>
                </div>
                <span class="text-gray-500 text-sm">考え中...</span>
            </div>
        </div>
    </div>
</div>
{% endmacro %}

以上がUI部分。

最後にロジック関連(&結果のHTML出力)部となるバックエンド処理をbackendフォルダ内にFastAPIで作成します。
(合わせて、backend内に__init__.pyファイルを空で作成しておきましょう)

backend/main.py
import os
import logging
from fastapi import FastAPI, Request, Form, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from databricks.sdk import WorkspaceClient
from starlette.middleware.sessions import SessionMiddleware

# --- Logging Setup ---
logging.basicConfig(
    level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
)
logger = logging.getLogger(__name__)

app = FastAPI(title="Simple HTMX Chat")

# セッションミドルウェアを追加
SECRET_KEY = os.getenv("SESSION_SECRET_KEY", "xxxxxx")
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
templates = Jinja2Templates(directory="templates")

SERVING_ENDPOINT = os.getenv("DATABRICKS_MODEL_ENDPOINT", "databricks-gpt-oss-20b")

w = WorkspaceClient()
openai_client = w.serving_endpoints.get_open_ai_client()


@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
    # セッションから会話履歴を取得
    conversation_history = request.session.get("conversation_history", [])
    return templates.TemplateResponse(
        request=request,
        name="index.html",
        context={"conversation_history": conversation_history},
    )


@app.post("/user-message", response_class=HTMLResponse)
async def user_message(request: Request, message: str = Form(...)):
    """
    Display user message immediately and add to conversation history
    """
    logger.info(f"Received user message: {message}")

    # セッションから会話履歴を取得
    conversation_history = request.session.get("conversation_history", [])

    # ユーザーメッセージを会話履歴に追加
    conversation_history.append({"role": "user", "content": message})

    # セッションに保存
    request.session["conversation_history"] = conversation_history

    # ユーザーメッセージのHTMLを返す
    return templates.TemplateResponse(
        request=request,
        name="user_message.html",
        context={"user_message": message},
    )


@app.post("/ai-response", response_class=HTMLResponse)
async def ai_response(request: Request, message: str = Form(...)):
    """
    Get AI response and add to conversation history
    """
    logger.info(f"Getting AI response for: {message}")

    try:
        # セッションから会話履歴を取得
        conversation_history = request.session.get("conversation_history", [])

        # システムメッセージを含むAPIコール用のメッセージリストを構築
        messages = [
            {
                "role": "system",
                "content": "You are a helpful AI assistant. Please respond in Japanese if the user writes in Japanese.",
            }
        ]

        # 会話履歴をメッセージに追加
        messages.extend(conversation_history)

        # 通常のレスポンスを取得(ストリーミングなし)
        response = openai_client.chat.completions.create(
            model=SERVING_ENDPOINT,
            messages=messages,
            max_tokens=4000,
            stream=False,
        )

        # レスポンスを取得
        ai_message = response.choices[0].message.content
        # 出力がリストになるエンドポイントの場合
        if isinstance(ai_message, list):
            for msg in ai_message:
                if isinstance(msg, dict) and msg.get("type") == "text":
                    ai_message = msg.get("text")
                    break

        # 会話履歴にAIの応答を追加
        conversation_history.append({"role": "assistant", "content": ai_message})

        # セッションに保存
        request.session["conversation_history"] = conversation_history

        logger.info(f"AI response completed: {ai_message[:100]}...")

        # AIレスポンスのHTMLを返す
        return templates.TemplateResponse(
            request=request,
            name="assistant_message.html",
            context={"assistant_message": ai_message},
        )

    except Exception as e:
        error_message = f"Error during AI response: {str(e)}"
        logger.error(error_message)
        return templates.TemplateResponse(
            request=request,
            name="assistant_message.html",
            context={"assistant_message": f"エラーが発生しました: {str(e)}"},
        )


@app.post("/clear-history")
async def clear_history(request: Request):
    """
    Clear conversation history from session
    """
    logger.info("Clearing conversation history")
    request.session["conversation_history"] = []
    return {"status": "success", "message": "Conversation history cleared"}

ここまで作成したらApps上でデプロイします。

チャットアプリを使ってみる

URLにアクセスすると簡単なチャットボットを利用できます。

image.png

これくらいだとChainlit使うとすぐ出来るものではありますが、デザインの自由度は非常に高く、かつシンプルに構築できると思いました。

おわりに

Databricks Appsでhtmx+Fast APIによるアプリ実装を試してみました。
今回はシンプルなチャットボットですが、ストリーミング出力や、マークダウンの表示などもう少し複雑な処理も勉強用に作ってみようかなと思います。

また、HTMLを中心にしたフロントエンド開発は、コーディングエージェントが本当に強いと感じています。
htmxの活用はAI協働開発との相性もいいので、アプリ開発の加速も期待できます。

技術スタックの選定はますます難しい時代になってきているように感じていますが、最適なものを状況で選べるように引出しを増やしていこうと思います。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?