本文へ移動
cccskills
無料GitHub で公開

openui-forge-elixir

OpenUI generative UI with Elixir Phoenix backend. Chunked SSE streaming via Plug.Conn and Req.

インストール方法を見る

含まれるファイル(1)

  • SKILL.md9.5 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

OpenUI Forge — Elixir

Build generative UI apps with a React frontend + Elixir Phoenix backend. Streams OpenAI API responses directly to the browser as SSE using Plug.Conn.send_chunked/2 and the Req HTTP client's streaming :into collector.

Activation Triggers

  • "openui elixir", "openui phoenix", "openui elixir backend"
  • "generative ui elixir", "elixir streaming ui backend", "phoenix sse openui"

Prerequisites

  • Node.js >= 22 (24 LTS recommended) + React >= 18.3.1 (19+ recommended) (frontend)
  • Elixir >= 1.15 with Erlang/OTP 25+ (Phoenix 1.8 baseline)
  • Phoenix ~> 1.8 (current stable; 1.8.8 as of June 2026)
  • OPENAI_API_KEY environment variable set

Quick Start

  1. Create the React frontend and install OpenUI deps:
npm install @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang lucide-react zod
  1. Generate the system prompt into the Phoenix app's priv/:
npx @openuidev/cli generate ./src/lib/library.ts --out backend/priv/system-prompt.txt
  1. Create the Phoenix backend (see Full Code below)
  2. Run: mix deps.get && mix phx.server on :4000, frontend on :3000

Full Code

Backend: backend/mix.exs (deps)

defp deps do
  [
    {:phoenix, "~> 1.8.0"},
    {:bandit, "~> 1.0"},      # HTTP server (Phoenix 1.8 default)
    {:jason, "~> 1.4"},       # JSON encode/decode
    {:req, "~> 0.6"},         # HTTP client with streaming :into (v0.6.2+)
    {:cors_plug, "~> 3.0"}    # explicit-origin CORS
  ]
end

Backend: load the system prompt once at startup

Read priv/system-prompt.txt a single time and cache it in :persistent_term. Call OpenuiBackend.load_system_prompt!/0 from your Application.start/2 callback before the endpoint child starts.

# lib/openui_backend.ex
defmodule OpenuiBackend do
  @key {__MODULE__, :system_prompt}

  def load_system_prompt! do
    path = Application.app_dir(:openui_backend, "priv/system-prompt.txt")

    case File.read(path) do
      {:ok, contents} ->
        :persistent_term.put(@key, contents)
        :ok

      {:error, reason} ->
        raise "failed to read #{path}: #{:file.format_error(reason)}"
    end
  end

  def system_prompt, do: :persistent_term.get(@key)
end
# lib/openui_backend/application.ex — inside start/2, before the children list
def start(_type, _args) do
  OpenuiBackend.load_system_prompt!()

  children = [
    OpenuiBackendWeb.Endpoint
    # ...
  ]

  Supervisor.start_link(children, strategy: :one_for_one, name: OpenuiBackend.Supervisor)
end

Backend: lib/openui_backend_web/controllers/chat_controller.ex

defmodule OpenuiBackendWeb.ChatController do
  use OpenuiBackendWeb, :controller

  require Logger

  @openai_chat_path "/chat/completions"

  # POST /api/chat
  def create(conn, %{"messages" => messages}) when is_list(messages) do
    case System.get_env("OPENAI_API_KEY") do
      key when is_binary(key) and key != "" -> stream_chat(conn, messages, key)
      _ -> send_error(conn, 500, "OPENAI_API_KEY not set")
    end
  end

  def create(conn, _params), do: send_error(conn, 400, "messages must be a non-empty array")

  defp stream_chat(conn, messages, api_key) do
    base_url = System.get_env("OPENAI_BASE_URL") || "https://api.openai.com/v1"
    model = System.get_env("OPENAI_MODEL") || "gpt-5.5"

    # Prepend the server-side system prompt; never trust the client to send it.
    system_message = %{"role" => "system", "content" => OpenuiBackend.system_prompt()}
    payload = %{"model" => model, "stream" => true, "messages" => [system_message | messages]}

    # Open the SSE response before the upstream call so the first byte flushes
    # to the browser as soon as OpenAI starts emitting tokens.
    conn =
      conn
      |> put_resp_content_type("text/event-stream")
      |> put_resp_header("cache-control", "no-cache")
      |> put_resp_header("x-accel-buffering", "no")
      |> send_chunked(200)

    result =
      Req.post(
        url: base_url <> @openai_chat_path,
        json: payload,
        auth: {:bearer, api_key},
        receive_timeout: :infinity,
        # `:into` turns the response body into a stream: Req hands each upstream
        # SSE chunk to this 2-arity collector as {:data, data}. We forward it
        # verbatim with chunk/2 and halt the moment the client disconnects,
        # mirroring the Enum.reduce_while halt-on-{:error,_} pattern. Plug.Conn
        # is immutable, so we thread the latest conn through resp.private.
        into: fn {:data, data}, {req, resp} ->
          out_conn = resp.private[:openui_conn] || conn

          case Plug.Conn.chunk(out_conn, data) do
            {:ok, out_conn} ->
              {:cont, {req, Req.Response.put_private(resp, :openui_conn, out_conn)}}

            {:error, reason} ->
              Logger.debug("client disconnected mid-stream: #{inspect(reason)}")
              {:halt, {req, resp}}
          end
        end
      )

    case result do
      {:ok, %Req.Response{status: status} = resp} when status in 200..299 ->
        # OpenAI already sends a terminating `data: [DONE]` line (forwarded verbatim).
        resp.private[:openui_conn] || conn

      {:ok, %Req.Response{status: status}} ->
        Logger.error("OpenAI returned HTTP #{status}")
        push_error_frame(conn, "OpenAI returned HTTP #{status}")

      {:error, exception} ->
        Logger.error("OpenAI request failed: #{Exception.message(exception)}")
        push_error_frame(conn, "OpenAI request unreachable")
    end
  end

  defp push_error_frame(conn, message) do
    payload = Jason.encode!(%{error: message})
    _ = Plug.Conn.chunk(conn, "data: #{payload}\n\ndata: [DONE]\n\n")
    conn
  end

  defp send_error(conn, status, message) do
    conn
    |> put_resp_content_type("application/json")
    |> send_resp(status, Jason.encode!(%{error: message}))
  end
end

Backend: lib/openui_backend_web/router.ex (scope)

defmodule OpenuiBackendWeb.Router do
  use OpenuiBackendWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/api", OpenuiBackendWeb do
    pipe_through :api

    post "/chat", ChatController, :create
  end
end

CORS lives in the endpoint, before the router, locked to the frontend origin (never a wildcard for the credentialed case):

# lib/openui_backend_web/endpoint.ex — before `plug OpenuiBackendWeb.Router`
plug CORSPlug,
  origin: [System.get_env("FRONTEND_ORIGIN") || "http://localhost:3000"],
  methods: ["POST", "OPTIONS"]

Frontend: app/chat/page.tsx

"use client";
import { FullScreen } from "@openuidev/react-ui";
import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib";
import {
  openAIAdapter,
  openAIMessageFormat,
} from "@openuidev/react-headless";

export default function ChatPage() {
  return (
    <FullScreen
      componentLibrary={openuiChatLibrary}
      streamProtocol={openAIAdapter()}
      messageFormat={openAIMessageFormat}
      apiUrl="http://localhost:4000/api/chat"
    />
  );
}

The Phoenix backend proxies OpenAI's native SSE stream verbatim: Req's :into collector receives each upstream {:data, data} chunk and re-emits it with Plug.Conn.chunk/2 (incremental flush, no buffering), so the browser sees data: {chunk}\n\n lines and the terminating data: [DONE]. Pair it with openAIAdapter() on the frontend. openAIReadableStreamAdapter() is for NDJSON (no data: prefix) and will silently produce no output here.

req_llm (built on Req + Finch) is a higher-level alternative that handles provider SSE adaptation if you later want typed multi-provider support. This skill keeps plain Req as the dependency-light default.

System Prompt Generation

npx @openuidev/cli generate ./src/lib/library.ts --out backend/priv/system-prompt.txt

Validation Checklist

  • priv/system-prompt.txt exists in the Phoenix backend
  • OpenuiBackend.load_system_prompt!/0 is called in Application.start/2 before the endpoint
  • OPENAI_API_KEY is set in environment (honor OPENAI_BASE_URL for OpenAI-compatible providers)
  • CORS plug allows the frontend origin explicitly (not *)
  • Response streams SSE directly from OpenAI API (Req :into passthrough, chunk/2 per chunk)
  • Frontend apiUrl points to http://localhost:4000/api/chat
  • Frontend uses streamProtocol={openAIAdapter()} and openAIMessageFormat
  • componentLibrary={openuiChatLibrary} prop passed to FullScreen
  • CSS import in root layout (@openuidev/react-ui/components.css)

Error Patterns

ErrorCauseFix
CORS blockedOrigin mismatchSet FRONTEND_ORIGIN and confirm CORSPlug runs before the router
:persistent_term key not foundPrompt not loaded at bootCall OpenuiBackend.load_system_prompt!/0 in Application.start/2
(File.Error) could not read priv/system-prompt.txtFile missingRun the CLI generate command into priv/
500 OPENAI_API_KEY not setEnv var missingExport OPENAI_API_KEY before mix phx.server
Empty responseWrong frontend adapterBackend emits SSE; use openAIAdapter(), not openAIReadableStreamAdapter()
Stream stalls / cut at ~60sCowboy idle timeoutOn Bandit (default) no cap; on Cowboy set protocol_options: [idle_timeout: :infinity]
function send_chunked/2 undefinedPlug.Conn not importedUse use OpenuiBackendWeb, :controller (imports Plug.Conn) or call Plug.Conn.send_chunked/2

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

Build generative UI with OpenUI — any LLM provider, any backend language. Scaffold, integrate, validate.

日本語の概要は準備中です。原文の説明を表示しています。

OthmanAdi/openui-forge222026年8月3日 更新

OpenUI generative UI with Anthropic Claude SDK backend. Stream conversion to OpenAI NDJSON format.

日本語の概要は準備中です。原文の説明を表示しています。

OthmanAdi/openui-forge222026年8月3日 更新

OpenUI generative UI with C# ASP.NET Core Minimal API backend. Direct OpenAI API SSE streaming via HttpClient on .NET 10.

日本語の概要は準備中です。原文の説明を表示しています。

OthmanAdi/openui-forge222026年8月3日 更新

OpenUI generative UI with Go (net/http) backend. Direct OpenAI API streaming via HTTP.

日本語の概要は準備中です。原文の説明を表示しています。

OthmanAdi/openui-forge222026年8月3日 更新

OpenUI generative UI with a Java Spring Boot (WebFlux) backend. Streams the OpenAI API directly via WebClient as SSE.

日本語の概要は準備中です。原文の説明を表示しています。

OthmanAdi/openui-forge222026年8月3日 更新

OpenUI generative UI with LangChain/LangGraph backend. Supports ChatOpenAI and ChatAnthropic.

日本語の概要は準備中です。原文の説明を表示しています。

OthmanAdi/openui-forge222026年8月3日 更新

OthmanAdi のスキルをすべて見る

このスキルの問題を報告する