HyperGen

エージェント統合

任意のAIエージェントをHyperGenに接続 — Claude、OpenAI、Ollama、カスタムシステム。パターンは常に同じ — エージェントがHTMLを生成し、サーバーがSSE経由でストリーミング。

エージェント統合

HyperGenは設計上エージェント非依存です。HTML文字列を生成できるシステムなら何でも、HyperGenを通じてUIを生成できます。このガイドでは、いくつかの代表的なエージェントバックエンドとの統合パターンを示します。

普遍的なパターン

すべてのHyperGenエージェント統合は同じ3つのステップに従います:

  1. エージェントがHTML文字列を生成(インタラクティビティ用のHTMX属性とテーマ設定用の--hg-* CSS変数付き)
  2. サーバーがHTMLを非同期ジェネレーターにyield
  3. createSSEStream()がSSE経由でiframeに配信
エージェント (任意)  ──HTML文字列──>  サーバー (非同期ジェネレーター)  ──SSE──>  iframe (HTMXがレンダリング)

サーバーサイドの非同期ジェネレーターが統合ポイントです。その前はすべてエージェント固有で、その後はすべてHyperGenのドロップインが処理します。

Claude Agent SDK

この例はexamples/claude-agent/ディレクトリに基づいています。

パターン: Claude Agent SDKのquery()SDKMessageオブジェクトをyieldします。アダプター関数が各メッセージをHTMLフラグメントに変換します。

import { createSSEStream, HyperGenResponse } from "./hypergen-server";
import { AgentSDK, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";

const agent = new AgentSDK({
  model: "claude-sonnet-4-20250514",
  tools: [weatherTool, searchTool],
});

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      const stream = agent.query("Show me the weather in Tokyo");

      for await (const message of stream) {
        const html = sdkMessageToHtml(message);
        if (html) yield html;
      }
    }),
  );
});

アダプター関数がSDKメッセージタイプをHTMLにマッピングします:

function sdkMessageToHtml(message: SDKMessage): string | null {
  switch (message.type) {
    case "assistant": {
      // アシスタントのレスポンスからテキストブロックを抽出
      const textBlocks = message.message.content.filter(
        (block) => block.type === "text",
      );
      if (textBlocks.length === 0) return null;

      const text = textBlocks.map((b) => b.text).join("");
      return `<div style="
        border-left: 3px solid var(--hg-accent, #4f46e5);
        padding: 12px 16px;
        margin: 8px 0;
        background: var(--hg-surface-elevated, #f9fafb);
        border-radius: var(--hg-radius, 8px);
        line-height: 1.6;
      ">${text}</div>`;
    }

    case "result": {
      if (message.subtype === "success") {
        return `<div style="
          background: var(--hg-surface-elevated, #f9fafb);
          padding: 16px;
          border-radius: var(--hg-radius, 8px);
          border: 1px solid var(--hg-border, #e5e7eb);
        ">${message.result}</div>`;
      }
      return `<div style="color: var(--hg-error, #ef4444);">
        Agent error: ${message.subtype}
      </div>`;
    }

    default:
      return null;
  }
}

ツール結果をインタラクティブHTMLに

ツールの結果をHTMX属性付きのインタラクティブHTMLに変換することで、真の力が発揮されます:

function renderWeatherCard(data: WeatherData): string {
  return `<div id="weather-${data.city}" style="
    background: var(--hg-surface-elevated);
    border: 1px solid var(--hg-border);
    border-radius: var(--hg-radius);
    padding: 16px;
    margin: 8px 0;
  ">
    <h3 style="color: var(--hg-text);">${data.city}</h3>
    <p style="font-size: 36px; font-weight: bold; color: var(--hg-accent);">
      ${data.temperature}&deg;C
    </p>
    <p style="color: var(--hg-text-muted);">${data.conditions}</p>
    <button
      hx-post="/api/action"
      hx-vals='{"action": "refresh_weather", "city": "${data.city}"}'
      hx-target="#weather-${data.city}"
      hx-swap="outerHTML"
      style="
        background: var(--hg-accent);
        color: var(--hg-accent-fg);
        border: none;
        padding: 8px 16px;
        border-radius: var(--hg-radius-sm);
        cursor: pointer;
        margin-top: 8px;
      ">
      Refresh
    </button>
  </div>`;
}

ユーザーが「Refresh」をクリックすると、HTMXが/api/actionにPOSTし、サーバーがエージェントを再度呼び出し(または最新データを取得し)、HTMXがスワップする新しいHTMLを返します。

OpenAI互換API

OpenAI、AnthropicのMessages API、または任意のOpenAI互換エンドポイント(Groq、Togetherなど)向け:

import { createSSEStream, HyperGenResponse } from "./hypergen-server";

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      const response = await fetch("https://api.openai.com/v1/chat/completions", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
        },
        body: JSON.stringify({
          model: "gpt-4o",
          messages: [
            {
              role: "system",
              content: `You are a UI generator. Output valid HTML fragments styled with CSS variables:
                --hg-surface, --hg-surface-elevated, --hg-text, --hg-text-muted,
                --hg-accent, --hg-accent-fg, --hg-border, --hg-radius, --hg-space-4.
                Use HTMX attributes (hx-post, hx-target, hx-swap) for interactivity.
                Respond with ONLY HTML, no markdown, no explanation.`,
            },
            {
              role: "user",
              content: "Show me a dashboard with a counter and a task list",
            },
          ],
        }),
      });

      const data = await response.json();
      const html = data.choices[0].message.content;

      yield html;
    }),
  );
});

トークン単位のストリーミング

リアルタイムのプログレッシブレンダリングには、OpenAIのストリーミングAPIを使用し、完全なフラグメントが得られるまでHTMLを蓄積します:

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      const response = await fetch("https://api.openai.com/v1/chat/completions", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
        },
        body: JSON.stringify({
          model: "gpt-4o",
          stream: true,
          messages: [
            { role: "system", content: "Output HTML styled with --hg-* CSS variables." },
            { role: "user", content: "Generate a welcome card" },
          ],
        }),
      });

      let buffer = "";
      const reader = response.body!.getReader();
      const decoder = new TextDecoder();

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

        const text = decoder.decode(value);
        for (const line of text.split("\n")) {
          if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
          const json = JSON.parse(line.slice(6));
          const delta = json.choices[0]?.delta?.content;
          if (delta) buffer += delta;
        }
      }

      // 完成したHTMLフラグメントをyield
      if (buffer) yield buffer;
    }),
  );
});

フラグメント境界

タイプライター効果を出すために到着した部分的なHTMLをyieldする(swapStrategy: "beforeend"を使用)か、完全なレスポンスをバッファリングして一度にyieldしてクリーンなレンダリングにするか選べます。正しい選択はUXによります。

ローカルLLM(Ollama)

OllamaはLLMをローカルで実行し、OpenAI互換APIを公開します:

import { createSSEStream, HyperGenResponse } from "./hypergen-server";

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      const response = await fetch("http://localhost:11434/api/generate", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          model: "llama3",
          prompt: `Generate an HTML dashboard card styled with these CSS variables:
            var(--hg-surface-elevated), var(--hg-text), var(--hg-accent),
            var(--hg-border), var(--hg-radius), var(--hg-space-4).
            Use hx-post="/api/action" for interactive buttons.
            Output ONLY valid HTML, no markdown.`,
          stream: false,
        }),
      });

      const data = await response.json();
      yield data.response;
    }),
  );
});

または、OllamaのOpenAI互換エンドポイントをドロップインで使用:

// OpenAIの例と同じコード、URLとモデルを変更するだけ
const response = await fetch("http://localhost:11434/v1/chat/completions", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    model: "llama3",
    messages: [
      { role: "system", content: "Output HTML styled with --hg-* CSS variables." },
      { role: "user", content: "Generate a task list" },
    ],
  }),
});

カスタムエージェント(非LLM)

HyperGenはLLMを必要としません。HTMLを生成できるシステムなら何でも「エージェント」になれます — ルールベースエンジン、テンプレートシステム、データベース駆動のジェネレーターなど。

ルールベースシステム

import { createSSEStream, HyperGenResponse } from "./hypergen-server";

interface Alert {
  severity: "info" | "warning" | "critical";
  message: string;
  timestamp: Date;
}

function alertToHtml(alert: Alert): string {
  const colors = {
    info: "var(--hg-accent, #3b82f6)",
    warning: "var(--hg-warning, #f59e0b)",
    critical: "var(--hg-error, #ef4444)",
  };

  return `<div style="
    border-left: 4px solid ${colors[alert.severity]};
    background: var(--hg-surface-elevated);
    padding: 12px 16px;
    margin: 8px 0;
    border-radius: var(--hg-radius);
  ">
    <strong style="color: ${colors[alert.severity]};">
      ${alert.severity.toUpperCase()}
    </strong>
    <p style="color: var(--hg-text); margin: 4px 0 0;">${alert.message}</p>
    <small style="color: var(--hg-text-muted);">
      ${alert.timestamp.toLocaleTimeString()}
    </small>
    <button
      hx-post="/api/action"
      hx-vals='{"action": "acknowledge", "id": "${alert.timestamp.getTime()}"}'
      hx-target="closest div"
      hx-swap="outerHTML"
      style="
        display: block; margin-top: 8px;
        background: none; border: 1px solid var(--hg-border);
        padding: 4px 12px; border-radius: var(--hg-radius-sm);
        cursor: pointer; color: var(--hg-text-muted);
      ">
      Acknowledge
    </button>
  </div>`;
}

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      // 監視システムからアラートをストリーミング
      for await (const alert of monitoringSystem.subscribe()) {
        yield alertToHtml(alert);
      }
    }),
  );
});

データベース駆動ジェネレーター

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      const records = await db.query("SELECT * FROM products LIMIT 10");

      yield `<h2 style="color: var(--hg-text); margin-bottom: var(--hg-space-4);">
        Products (${records.length})
      </h2>`;

      for (const record of records) {
        yield `<div style="
          display: flex; justify-content: space-between; align-items: center;
          padding: var(--hg-space-2) var(--hg-space-4);
          border-bottom: 1px solid var(--hg-border);
        ">
          <div>
            <strong style="color: var(--hg-text);">${record.name}</strong>
            <p style="color: var(--hg-text-muted); margin: 0;">$${record.price}</p>
          </div>
          <button
            hx-post="/api/action"
            hx-vals='{"action": "view_product", "id": "${record.id}"}'
            hx-target="#product-detail"
            hx-swap="innerHTML"
            style="background: var(--hg-accent); color: var(--hg-accent-fg); border: none; padding: 6px 12px; border-radius: var(--hg-radius-sm); cursor: pointer;">
            View
          </button>
        </div>`;
      }

      yield `<div id="product-detail" style="margin-top: var(--hg-space-4);"></div>`;
    }),
  );
});

ベストプラクティス

LLMエージェント用のシステムプロンプト

LLMを使用する場合、システムプロンプトに以下の指示を含めてください:

You are a UI generator. Output valid HTML fragments with:
- CSS variables for theming: var(--hg-surface), var(--hg-text), var(--hg-accent), etc.
- HTMX attributes for interactivity: hx-post, hx-target, hx-swap, hx-vals
- Self-contained fragments (include <style> blocks if needed)
- No markdown, no code fences, no explanation — just HTML

エージェント出力のバリデーション

LLMは不正なHTMLを生成する可能性があります。バリデーションステップの追加を検討してください:

function sanitizeHtml(html: string): string {
  // LLMが時々追加するマークダウンのコードフェンスを除去
  return html
    .replace(/^```html?\n?/i, "")
    .replace(/\n?```$/i, "")
    .trim();
}

app.get("/api/stream", () => {
  return HyperGenResponse(
    createSSEStream(async function* () {
      const raw = await agent.generate("Create a card");
      yield sanitizeHtml(raw);
    }),
  );
});

エラーハンドリング

エージェント呼び出しをtry/catchで囲み、エラーUIをyieldします:

createSSEStream(async function* () {
  try {
    for await (const fragment of agent.stream()) {
      yield fragment;
    }
  } catch (error) {
    yield `<div style="
      background: var(--hg-surface-elevated);
      border-left: 4px solid var(--hg-error, #ef4444);
      padding: 12px 16px;
      border-radius: var(--hg-radius);
      color: var(--hg-text);
    ">
      <strong>Something went wrong</strong>
      <p style="color: var(--hg-text-muted); margin-top: 4px;">
        ${error instanceof Error ? error.message : "Unknown error"}
      </p>
      <button
        hx-get="/api/stream"
        hx-target="#hg-root"
        hx-swap="innerHTML"
        style="margin-top: 8px; background: var(--hg-accent); color: var(--hg-accent-fg); border: none; padding: 8px 16px; border-radius: var(--hg-radius-sm); cursor: pointer;">
        Retry
      </button>
    </div>`;
  }
});

目次