diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/langgraph.json b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/langgraph.json new file mode 100644 index 000000000..fa46b9f8f --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/langgraph.json @@ -0,0 +1,8 @@ +{ + "node_version": "20", + "dependencies": ["."], + "graphs": { + "agent": "./src/agent/agent.ts:graph" + }, + "env": ".env" +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/manifest.json b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/manifest.json new file mode 100644 index 000000000..350ad76ab --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/manifest.json @@ -0,0 +1,23 @@ +{ + "packageJson": { + "dependencies": { + "@langchain/core": "^1.2.5", + "@langchain/langgraph": "^1.4.9", + "@langchain/openai": "^1.5.6", + "@openuidev/langchain": "latest", + "langchain": "^1.5.5", + "zod": "^4.4.3" + }, + "devDependencies": { + "@langchain/langgraph-cli": "^1.4.4", + "npm-run-all2": "^9.0.2" + }, + "scripts": { + "dev": "run-p dev:langgraph dev:next", + "dev:langgraph": "langgraphjs dev", + "dev:next": "next dev" + }, + "removeDependencies": ["openai"] + }, + "gettingStarted": "The generated LangGraph backend is a standalone Agent Server. `{{packageManager}} run dev` starts both the Agent Server and Next.js. Deploy the Next.js frontend to Vercel and point LANGGRAPH_API_URL at wherever the Agent Server runs.\nAsk \"What's the weather in Berlin?\" to exercise its native tool loop." +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/agent/agent.ts b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/agent/agent.ts new file mode 100644 index 000000000..15d9eefe5 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/agent/agent.ts @@ -0,0 +1,38 @@ +import { tool } from "@langchain/core/tools"; +import { ChatOpenAI } from "@langchain/openai"; +import { generateSystemPrompt } from "@openuidev/lang-core"; +import { openUIStreamTransformer } from "@openuidev/langchain/transformer"; +import { createAgent } from "langchain"; +import { z } from "zod"; +import librarySpec from "../generated/spec.json"; +import { promptOptions } from "../lib/prompt-options"; +import { getWeather, WEATHER_TOOL_DESCRIPTION } from "../lib/tools/get-weather"; + +const getWeatherTool = tool( + async ({ location }, config) => + JSON.stringify(await getWeather(location, { signal: config.signal })), + { + name: "get_weather", + description: WEATHER_TOOL_DESCRIPTION, + schema: z.object({ + location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), + }), + }, +); + +const model = new ChatOpenAI({ + model: process.env.OPENAI_MODEL ?? "gpt-5.2", + streaming: true, + configuration: process.env.OPENAI_BASE_URL ? { baseURL: process.env.OPENAI_BASE_URL } : undefined, +}); + +/** + * A standalone LangGraph agent: LangGraph owns orchestration and tool execution, + * while the application supplies its OpenAI-compatible model provider. + */ +export const graph = createAgent({ + model, + tools: [getWeatherTool], + systemPrompt: generateSystemPrompt({ library: librarySpec, promptOptions }), + streamTransformers: [openUIStreamTransformer], +}); diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/api/chat/route.ts new file mode 100644 index 000000000..82207adfc --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/api/chat/route.ts @@ -0,0 +1,19 @@ +import { createLangChainStreamResponse } from "@openuidev/langchain"; + +export const runtime = "nodejs"; + +const API_URL = process.env.LANGGRAPH_API_URL || "http://localhost:2024"; +const ASSISTANT_ID = process.env.LANGGRAPH_ASSISTANT_ID || "agent"; + +/** + * Browser-to-Agent-Server proxy. The agent itself lives in src/agent/agent.ts + * and can be run locally or deployed independently. + */ +export async function POST(request: Request) { + return createLangChainStreamResponse(request, { + apiUrl: API_URL, + assistantId: ASSISTANT_ID, + apiKey: process.env.LANGSMITH_API_KEY, + debug: process.env.NODE_ENV !== "production", + }); +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/page.tsx b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/page.tsx new file mode 100644 index 000000000..4cb904e45 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/page.tsx @@ -0,0 +1,19 @@ +"use client"; +import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/styles/index.css"; + +import { AgentInterface, agUIAdapter, fetchLLM } from "@openuidev/react-ui"; +import { openuiLibrary } from "@openuidev/react-ui/genui-lib"; + +const llm = fetchLLM({ + url: "/api/chat", + streamAdapter: agUIAdapter(), +}); + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/manifest.json b/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/manifest.json new file mode 100644 index 000000000..57ba335ec --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/manifest.json @@ -0,0 +1,11 @@ +{ + "packageJson": { + "dependencies": { + "@ai-sdk/openai": "3.0.90", + "ai": "6.0.244", + "zod": "^4.4.3" + }, + "removeDependencies": ["openai"] + }, + "gettingStarted": "The generated API route uses the Vercel AI SDK.\nAsk \"What's the weather in Berlin?\" to exercise its native tool loop." +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/api/chat/route.ts new file mode 100644 index 000000000..da856d41c --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/api/chat/route.ts @@ -0,0 +1,44 @@ +import librarySpec from "@/generated/spec.json"; +import { promptOptions } from "@/lib/prompt-options"; +import { getWeather, WEATHER_TOOL_DESCRIPTION } from "@/lib/tools/get-weather"; +import { createOpenAI } from "@ai-sdk/openai"; +import { generateSystemPrompt } from "@openuidev/lang-core"; +import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from "ai"; +import { z } from "zod"; + +export const runtime = "nodejs"; + +const openai = createOpenAI({ + baseURL: process.env.OPENAI_BASE_URL, + apiKey: process.env.OPENAI_API_KEY, +}); + +const tools = { + get_weather: tool({ + description: WEATHER_TOOL_DESCRIPTION, + inputSchema: z.object({ + location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), + }), + execute: ({ location }, { abortSignal }) => getWeather(location, { signal: abortSignal }), + }), +}; + +export async function POST(req: Request) { + const payload = (await req.json()) as { messages?: UIMessage[] }; + if (!Array.isArray(payload.messages)) { + return Response.json({ error: "messages must be an array" }, { status: 400 }); + } + + const result = streamText({ + model: openai.chat(process.env.OPENAI_MODEL ?? "gpt-5.2"), + system: generateSystemPrompt({ library: librarySpec, promptOptions }), + messages: await convertToModelMessages(payload.messages), + tools, + stopWhen: stepCountIs(5), + abortSignal: req.signal, + }); + + // Preserve the AI SDK's native UIMessage stream. The frontend adapter uses + // the AI SDK itself to decode it before mapping chunks into OpenUI events. + return result.toUIMessageStreamResponse(); +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/page.tsx b/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/page.tsx new file mode 100644 index 000000000..5f5b9cb55 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/page.tsx @@ -0,0 +1,25 @@ +"use client"; +import "@openuidev/react-ui/components.css"; +import "@openuidev/react-ui/styles/index.css"; + +import { + AgentInterface, + fetchLLM, + vercelAIAdapter, + vercelAIMessageFormat, +} from "@openuidev/react-ui"; +import { openuiLibrary } from "@openuidev/react-ui/genui-lib"; + +const llm = fetchLLM({ + url: "/api/chat", + streamAdapter: vercelAIAdapter(), + messageFormat: vercelAIMessageFormat, +}); + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/packages/openui-cli/src/templates/openui-self-hosted/src/lib/tools/get-weather.ts b/packages/openui-cli/src/templates/openui-self-hosted/src/lib/tools/get-weather.ts new file mode 100644 index 000000000..08a38246b --- /dev/null +++ b/packages/openui-cli/src/templates/openui-self-hosted/src/lib/tools/get-weather.ts @@ -0,0 +1,74 @@ +/** Example app-owned tool: current weather via Open-Meteo (free, no API key). */ + +export const WEATHER_TOOL_DESCRIPTION = + "Get the current weather for a city or place name. Use whenever the user " + + "asks about weather, temperature, rain, or what to wear."; + +export type WeatherResult = + | { + place: string; + temperature_c: number; + conditions: string; + wind_kmh: number; + } + | { error: string }; + +function describeWeather(code: number): string { + if (code === 0) return "clear sky"; + if (code <= 3) return "partly cloudy"; + if (code <= 48) return "fog"; + if (code <= 57) return "drizzle"; + if (code <= 67) return "rain"; + if (code <= 77) return "snow"; + if (code <= 82) return "rain showers"; + if (code <= 86) return "snow showers"; + return "thunderstorm"; +} + +async function fetchJson(url: URL, signal?: AbortSignal): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Open-Meteo returned ${response.status}`); + } + return (await response.json()) as T; +} + +export async function getWeather( + locationInput: string, + options: { signal?: AbortSignal } = {}, +): Promise { + const location = locationInput.trim(); + if (!location) return { error: "location is required" }; + + try { + const geoUrl = new URL("https://geocoding-api.open-meteo.com/v1/search"); + geoUrl.searchParams.set("name", location); + geoUrl.searchParams.set("count", "1"); + const geo = await fetchJson<{ + results?: Array<{ name: string; country?: string; latitude: number; longitude: number }>; + }>(geoUrl, options.signal); + const place = geo.results?.[0]; + if (!place) return { error: `No place found for "${location}"` }; + + const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast"); + weatherUrl.searchParams.set("latitude", String(place.latitude)); + weatherUrl.searchParams.set("longitude", String(place.longitude)); + weatherUrl.searchParams.set("current", "temperature_2m,weather_code,wind_speed_10m"); + const weather = await fetchJson<{ + current?: { temperature_2m: number; weather_code: number; wind_speed_10m: number }; + }>(weatherUrl, options.signal); + if (!weather.current) return { error: "No weather data returned" }; + + return { + place: `${place.name}${place.country ? `, ${place.country}` : ""}`, + temperature_c: weather.current.temperature_2m, + conditions: describeWeather(weather.current.weather_code), + wind_kmh: weather.current.wind_speed_10m, + }; + } catch (error) { + if (options.signal?.aborted) throw error; + return { + error: `Weather lookup failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +}