From 73826417450e8c50fc0e5b70f5eb1af75f409e21 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 23 Aug 2026 22:39:39 +0530 Subject: [PATCH 1/3] feat(cli): add self-hosted LangGraph and Vercel AI SDK backends --- .../backends/langgraph/langgraph.json | 8 ++ .../backends/langgraph/manifest.json | 23 ++++++ .../backends/langgraph/src/agent/agent.ts | 38 ++++++++++ .../langgraph/src/app/api/chat/route.ts | 19 +++++ .../backends/langgraph/src/app/page.tsx | 19 +++++ .../backends/vercel-ai-sdk/manifest.json | 11 +++ .../vercel-ai-sdk/src/app/api/chat/route.ts | 44 +++++++++++ .../backends/vercel-ai-sdk/src/app/page.tsx | 25 +++++++ .../src/lib/tools/get-weather.ts | 74 +++++++++++++++++++ 9 files changed, 261 insertions(+) create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/langgraph.json create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/manifest.json create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/agent/agent.ts create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/api/chat/route.ts create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/langgraph/src/app/page.tsx create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/manifest.json create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/api/chat/route.ts create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/backends/vercel-ai-sdk/src/app/page.tsx create mode 100644 packages/openui-cli/src/templates/openui-self-hosted/src/lib/tools/get-weather.ts 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)}`, + }; + } +} From a1c97f3e9d72b209cc541d4b503dbd9990b9e51c Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Sun, 23 Aug 2026 22:39:39 +0530 Subject: [PATCH 2/3] feat(cli): add OpenUI Cloud LangGraph and Vercel AI SDK backends --- .../backends/langgraph/langgraph.json | 8 + .../backends/langgraph/manifest.json | 25 ++ .../backends/langgraph/src/agent/agent.ts | 120 +++++++++ .../langgraph/src/app/api/chat/route.ts | 37 +++ .../langgraph/src/components/cloud-chat.tsx | 92 +++++++ .../backends/vercel-ai-sdk/manifest.json | 14 ++ .../vercel-ai-sdk/src/app/api/chat/route.ts | 235 ++++++++++++++++++ .../src/components/cloud-chat.tsx | 94 +++++++ .../src/templates/openui-cloud/gitignore | 3 + 9 files changed, 628 insertions(+) create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/langgraph/langgraph.json create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/langgraph/manifest.json create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/app/api/chat/route.ts create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/components/cloud-chat.tsx create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/manifest.json create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts create mode 100644 packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/components/cloud-chat.tsx diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/langgraph.json b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/langgraph.json new file mode 100644 index 000000000..fa46b9f8f --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/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-cloud/backends/langgraph/manifest.json b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/manifest.json new file mode 100644 index 000000000..d6252f0fd --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/manifest.json @@ -0,0 +1,25 @@ +{ + "packageJson": { + "dependencies": { + "@langchain/core": "^1.2.5", + "@langchain/langgraph": "^1.4.9", + "@langchain/openai": "^1.5.6", + "@openuidev/langchain": "latest", + "langchain": "^1.5.5" + }, + "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"] + }, + "files": { + "remove": ["src/lib/tool-loop.ts"] + }, + "gettingStarted": "The generated LangGraph agent uses OpenUI Cloud as its Responses provider. `{{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 the included LangGraph tool." +} diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts new file mode 100644 index 000000000..5a3721cda --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts @@ -0,0 +1,120 @@ +import { AIMessage } from "@langchain/core/messages"; +import { type ServerTool, tool } from "@langchain/core/tools"; +import { StateSchema } from "@langchain/langgraph"; +import { ChatOpenAI } from "@langchain/openai"; +import { openUIStreamTransformer } from "@openuidev/langchain/transformer"; +import { artifactTool, generateSystemPrompt } from "@openuidev/thesys-server"; +import { createAgent, createMiddleware } from "langchain"; +import { z } from "zod"; + +import { requiredEnv } from "../lib/env"; +import { DEFAULT_MODEL } from "../lib/models"; +import { executeGetWeather, getWeatherTool } from "../lib/tools/get-weather"; + +const getWeather = tool( + async ({ location }, config) => + executeGetWeather(JSON.stringify({ location }), { signal: config.signal }), + { + name: "get_weather", + description: getWeatherTool.description, + schema: z.object({ + location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), + }), + }, +); + +const appTools = [getWeather]; +const appToolNames = new Set(appTools.map(({ name }) => name)); +const TOOL_CALL_BLOCK_TYPES = new Set(["tool_call", "tool_call_chunk", "tool_use"]); + +function keepAppToolCallBlocks(block: unknown) { + if (typeof block !== "object" || block === null) return true; + const { type, name } = block as { type?: unknown; name?: unknown }; + if (typeof type !== "string" || !TOOL_CALL_BLOCK_TYPES.has(type)) return true; + return typeof name === "string" && appToolNames.has(name); +} + +// These are provider-executed tools. LangGraph sends their declarations to +// OpenUI Cloud, while Cloud runs them and stores their outputs/artifacts. +const cloudTools = [ + artifactTool({ artifacts: ["slides", "report"] }), + { type: "web_search" }, + { type: "image_search" }, + // Add provider-executed MCP servers here, for example: + // { type: "mcp", server_label: "deepwiki", server_url: "https://mcp.deepwiki.com/mcp" }, +] as ServerTool[]; + +const CloudAgentState = new StateSchema({ + conversationId: z.string(), + model: z.string().default(DEFAULT_MODEL), +}); + +function cloudModel(model: string, conversationId?: string) { + return new ChatOpenAI({ + model, + apiKey: requiredEnv("THESYS_API_KEY"), + streaming: true, + useResponsesApi: true, + configuration: { baseURL: "https://api.thesys.dev/v1/embed" }, + modelKwargs: { + store: true, + ...(conversationId ? { conversation: conversationId } : {}), + }, + }); +} + +const cloudConversation = createMiddleware({ + name: "OpenUICloudConversation", + stateSchema: CloudAgentState, + wrapModelCall: async (request, handler) => { + const { conversationId, model } = request.state as unknown as { + conversationId: string; + model: string; + }; + + const response = await handler({ + ...request, + model: cloudModel(model, conversationId), + // Cloud has the earlier turns. Within a LangGraph run this becomes the + // latest user message first, then each locally produced ToolMessage. + messages: request.messages.slice(-1), + }); + + // Cloud has already executed its provider tools. Keep only app-owned + // calls in graph state so LangGraph's ToolNode executes exactly those. + // ChatOpenAI also derives tool_calls from standard content blocks, so + // remove Cloud-owned call blocks as well as filtering response.tool_calls. + const localToolCalls = response.tool_calls?.filter(({ name }) => appToolNames.has(name)); + const localContent = Array.isArray(response.content) + ? response.content.filter(keepAppToolCallBlocks) + : response.content; + const contentChanged = + Array.isArray(response.content) && localContent.length !== response.content.length; + if (localToolCalls?.length === response.tool_calls?.length && !contentChanged) { + return response; + } + + return new AIMessage({ + id: response.id, + content: localContent, + additional_kwargs: response.additional_kwargs, + response_metadata: response.response_metadata, + tool_calls: localToolCalls, + invalid_tool_calls: response.invalid_tool_calls, + usage_metadata: response.usage_metadata, + }); + }, +}); + +/** + * A normal LangGraph agent: LangGraph owns orchestration and local + * tool execution; OpenUI Cloud is the attached Responses provider. + */ +export const graph = createAgent({ + model: cloudModel(DEFAULT_MODEL), + tools: [...cloudTools, ...appTools], + systemPrompt: generateSystemPrompt(), + stateSchema: CloudAgentState, + middleware: [cloudConversation], + streamTransformers: [openUIStreamTransformer], +}); diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/app/api/chat/route.ts new file mode 100644 index 000000000..0bbc59341 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/app/api/chat/route.ts @@ -0,0 +1,37 @@ +import { resolveRequestedModel } from "@/lib/models"; +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", + prepareInput: ({ messages, requestBody }) => { + const conversationId = requestBody.threadId; + if (typeof conversationId !== "string" || !conversationId) { + throw new Error("threadId is required — create the conversation first"); + } + + const model = resolveRequestedModel(requestBody.model); + if (!model) throw new Error("model is not available in this agent"); + + return { + // OpenUI Cloud stores prior turns. LangGraph owns the current run and + // local tool loop, while only new inputs are sent to the conversation. + messages: messages.slice(-1), + conversationId, + model, + }; + }, + }); +} diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/components/cloud-chat.tsx b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/components/cloud-chat.tsx new file mode 100644 index 000000000..09e37aba2 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/components/cloud-chat.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { usePersistedModel } from "@/hooks/use-persisted-model"; +import { MODEL_OPTIONS } from "@/lib/models"; +import { OPENUI_LOGOS, PROMPT_TEMPLATES, STARTERS } from "@/lib/starters"; +import { + AgentInterface, + ModelSwitcher, + agUIAdapter, + defineArtifactCategories, + fetchLLM, + useSystemThemeMode, +} from "@openuidev/react-ui"; +import { + chatLibrary, + presentationArtifactRenderer, + reportArtifactRenderer, + useOpenuiCloudStorage, +} from "@openuidev/thesys"; +import { FileText, Presentation } from "lucide-react"; + +const { artifactRenderers, artifactCategories } = defineArtifactCategories([ + { + name: "Presentations", + renderers: [presentationArtifactRenderer], + icon: , + }, + { + name: "Reports", + renderers: [reportArtifactRenderer], + icon: , + }, +]); + +export default function CloudChat() { + const mode = useSystemThemeMode(); + const [selectedModel, setSelectedModel] = usePersistedModel(); + // The LangGraph proxy emits AG-UI events, so no message format is needed: + // the Agent Server receives the conversation as graph input instead. + const llm = fetchLLM({ + url: "/api/chat", + streamAdapter: agUIAdapter(), + body: { model: selectedModel }, + }); + + const storage = useOpenuiCloudStorage({ + token: "/api/frontend-token", + apiBaseUrl: "https://api.thesys.dev", + features: { artifact: true }, + }); + + const logoPath = mode === "dark" ? OPENUI_LOGOS.DARK : OPENUI_LOGOS.LIGHT; + + return ( +
+ + + } + /> + + + + + +
+ ); +} diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/manifest.json b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/manifest.json new file mode 100644 index 000000000..041283ab4 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/manifest.json @@ -0,0 +1,14 @@ +{ + "packageJson": { + "dependencies": { + "@ai-sdk/openai": "^3.0.91", + "ai": "^6.0.246", + "zod": "^4.4.3" + }, + "removeDependencies": ["openai"] + }, + "files": { + "remove": ["src/lib/tool-loop.ts"] + }, + "gettingStarted": "The generated Vercel AI SDK route uses OpenUI Cloud as its Responses provider and is deployable as a normal Next.js app on Vercel.\nAsk \"What's the weather in Berlin?\" to exercise the included AI SDK tool." +} diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts new file mode 100644 index 000000000..b4aba8fce --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts @@ -0,0 +1,235 @@ +import { requiredEnv } from "@/lib/env"; +import { resolveRequestedModel } from "@/lib/models"; +import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather"; +import { createOpenAI } from "@ai-sdk/openai"; +import { artifactTool, generateSystemPrompt } from "@openuidev/thesys-server"; +import { + convertToModelMessages, + createUIMessageStreamResponse, + stepCountIs, + streamText, + tool, + wrapLanguageModel, + type LanguageModelMiddleware, + type UIMessage, + type UIMessageChunk, +} from "ai"; +import { z } from "zod"; + +export const runtime = "nodejs"; + +const appTools = { + get_weather: tool({ + description: getWeatherTool.description, + inputSchema: z.object({ + location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), + }), + execute: ({ location }, { abortSignal }) => + executeGetWeather(JSON.stringify({ location }), { signal: abortSignal }), + }), +}; + +const appToolNames = new Set(Object.keys(appTools)); +for (const appToolName of appToolNames) { + if (appToolName.startsWith("thesys_")) { + throw new Error(`App tool names cannot use the reserved thesys_ prefix: ${appToolName}`); + } +} + +type WrapStreamArgs = Parameters>[0]; +type ModelStreamResult = Awaited>; +type ModelStreamPart = + ModelStreamResult["stream"] extends ReadableStream ? Part : never; + +type CloudFunctionCallOutput = { + type: "response.output_item.done"; + item: { + type: "function_call_output"; + call_id: string; + output: string; + }; +}; + +function isCloudFunctionCallOutput(value: unknown): value is CloudFunctionCallOutput { + if (typeof value !== "object" || value === null) return false; + const event = value as { type?: unknown; item?: unknown }; + if (event.type !== "response.output_item.done") return false; + if (typeof event.item !== "object" || event.item === null) return false; + const item = event.item as { type?: unknown; call_id?: unknown; output?: unknown }; + return ( + item.type === "function_call_output" && + typeof item.call_id === "string" && + typeof item.output === "string" + ); +} + +/** + * OpenUI Cloud runs artifacts, search, and MCP calls itself. Mark those calls + * as provider-executed dynamic tools so the AI SDK includes them in its stream + * without dispatching them through the local appTools executor. + */ +const cloudToolsAsProviderExecuted: LanguageModelMiddleware = { + specificationVersion: "v3", + wrapStream: async ({ doStream }) => { + const result = await doStream(); + const cloudToolCallIds = new Set(); + const cloudToolNames = new Map(); + + return { + ...result, + stream: result.stream.pipeThrough( + new TransformStream({ + transform(part, controller) { + if (part.type === "raw") { + if ( + isCloudFunctionCallOutput(part.rawValue) && + cloudToolCallIds.has(part.rawValue.item.call_id) + ) { + const toolCallId = part.rawValue.item.call_id; + const toolName = cloudToolNames.get(toolCallId); + if (toolName) { + controller.enqueue({ + type: "tool-result", + toolCallId, + toolName, + result: part.rawValue.item.output, + dynamic: true, + }); + } + } + return; + } + + if (part.type === "tool-input-start") { + if (part.providerExecuted === true || !appToolNames.has(part.toolName)) { + cloudToolCallIds.add(part.id); + cloudToolNames.set(part.id, part.toolName); + controller.enqueue({ ...part, providerExecuted: true, dynamic: true }); + return; + } + } + + if (part.type === "tool-call") { + if ( + part.providerExecuted === true || + cloudToolCallIds.has(part.toolCallId) || + !appToolNames.has(part.toolName) + ) { + cloudToolCallIds.add(part.toolCallId); + cloudToolNames.set(part.toolCallId, part.toolName); + controller.enqueue({ ...part, providerExecuted: true, dynamic: true }); + return; + } + } + + if (part.type === "tool-result" && cloudToolCallIds.has(part.toolCallId)) { + controller.enqueue({ ...part, dynamic: true }); + return; + } + + controller.enqueue(part); + }, + }), + ), + }; + }, +}; + +/** + * `providerExecuted` controls the AI SDK's backend tool loop. Once a completed + * Cloud tool reaches the browser it is display-only; removing that flag from + * the outgoing UI chunk lets OpenUI render the activity while the generic + * adapter keeps rejecting unscoped provider-executed streams by default. + */ +function displayOnlyProviderTools() { + return new TransformStream({ + transform(chunk, controller) { + if ("providerExecuted" in chunk && chunk.providerExecuted === true) { + const { providerExecuted: _providerExecuted, ...displayChunk } = chunk; + controller.enqueue(displayChunk as UIMessageChunk); + return; + } + + controller.enqueue(chunk); + }, + }); +} + +/** Add Cloud-managed tool declarations after the AI SDK prepares its request. */ +const cloudFetch: typeof fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (!url.endsWith("/responses") || typeof init?.body !== "string") { + return fetch(input, init); + } + + const body = JSON.parse(init.body) as { tools?: unknown[] }; + body.tools = [ + artifactTool({ artifacts: ["slides", "report"] }), + { type: "image_search" }, + // Add provider-executed MCP servers here, for example: + // { type: "mcp", server_label: "deepwiki", server_url: "https://mcp.deepwiki.com/mcp" }, + ...(body.tools ?? []), + ]; + + return fetch(input, { ...init, body: JSON.stringify(body) }); +}; + +export async function POST(req: Request) { + const { + threadId, + messages, + model: requestedModel, + } = (await req.json()) as { + threadId?: string; + messages?: UIMessage[]; + model?: unknown; + }; + + if (!threadId) return badRequest("threadId is required — create the conversation first"); + if (!Array.isArray(messages) || messages.length === 0) { + return badRequest("messages must be a non-empty UIMessage[]"); + } + const model = resolveRequestedModel(requestedModel); + if (!model) return badRequest("model is not available in this agent"); + + const openai = createOpenAI({ + baseURL: "https://api.thesys.dev/v1/embed", + apiKey: requiredEnv("THESYS_API_KEY"), + fetch: cloudFetch, + }); + const cloudModel = wrapLanguageModel({ + model: openai.responses(model), + middleware: cloudToolsAsProviderExecuted, + }); + + const result = streamText({ + model: cloudModel, + messages: await convertToModelMessages(messages.slice(-1)), + tools: { + ...appTools, + web_search: openai.tools.webSearch({}), + }, + stopWhen: stepCountIs(5), + prepareStep: ({ messages: stepMessages }) => ({ messages: stepMessages.slice(-1) }), + providerOptions: { + openai: { + conversation: threadId, + store: true, + instructions: generateSystemPrompt(), + }, + }, + abortSignal: req.signal, + // Cloud-specific function_call_output items are currently exposed by the + // OpenAI provider as raw chunks, which the middleware maps to tool results. + includeRawChunks: true, + }); + + // The AI SDK owns UIMessage SSE encoding for this variant. + return createUIMessageStreamResponse({ + stream: result.toUIMessageStream().pipeThrough(displayOnlyProviderTools()), + }); +} + +function badRequest(message: string): Response { + return Response.json({ error: { message } }, { status: 400 }); +} diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/components/cloud-chat.tsx b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/components/cloud-chat.tsx new file mode 100644 index 000000000..aafa3f5c5 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/components/cloud-chat.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { usePersistedModel } from "@/hooks/use-persisted-model"; +import { MODEL_OPTIONS } from "@/lib/models"; +import { OPENUI_LOGOS, PROMPT_TEMPLATES, STARTERS } from "@/lib/starters"; +import { + AgentInterface, + ModelSwitcher, + defineArtifactCategories, + fetchLLM, + useSystemThemeMode, + vercelAIAdapter, + vercelAIMessageFormat, +} from "@openuidev/react-ui"; +import { + chatLibrary, + presentationArtifactRenderer, + reportArtifactRenderer, + useOpenuiCloudStorage, +} from "@openuidev/thesys"; +import { FileText, Presentation } from "lucide-react"; + +const { artifactRenderers, artifactCategories } = defineArtifactCategories([ + { + name: "Presentations", + renderers: [presentationArtifactRenderer], + icon: , + }, + { + name: "Reports", + renderers: [reportArtifactRenderer], + icon: , + }, +]); + +export default function CloudChat() { + const mode = useSystemThemeMode(); + const [selectedModel, setSelectedModel] = usePersistedModel(); + // The route returns the AI SDK's native UIMessage stream, so the browser + // decodes it with the SDK's own adapter and message format. + const llm = fetchLLM({ + url: "/api/chat", + streamAdapter: vercelAIAdapter(), + messageFormat: vercelAIMessageFormat, + body: { model: selectedModel }, + }); + + const storage = useOpenuiCloudStorage({ + token: "/api/frontend-token", + apiBaseUrl: "https://api.thesys.dev", + features: { artifact: true }, + }); + + const logoPath = mode === "dark" ? OPENUI_LOGOS.DARK : OPENUI_LOGOS.LIGHT; + + return ( +
+ + + } + /> + + + + + +
+ ); +} diff --git a/packages/openui-cli/src/templates/openui-cloud/gitignore b/packages/openui-cli/src/templates/openui-cloud/gitignore index 3cceb2b4c..07b0ee2c1 100644 --- a/packages/openui-cli/src/templates/openui-cloud/gitignore +++ b/packages/openui-cli/src/templates/openui-cloud/gitignore @@ -36,6 +36,9 @@ yarn-error.log* # vercel .vercel +# local LangGraph Agent Server +.langgraph_api + # local thread index (created at runtime) /.data/ From f3aa7b6275b31cb0bab8f4a9de26918e1d1c4981 Mon Sep 17 00:00:00 2001 From: Abhin Rustagi Date: Mon, 24 Aug 2026 14:27:20 +0530 Subject: [PATCH 3/3] fix(cli): send all trailing tool results into Cloud conversation Parallel local tools produce multiple ToolMessages; slice(-1) dropped siblings so the second model step got an incomplete function_call_output set and returned no answer on the live stream. --- .../backends/langgraph/src/agent/agent.ts | 20 +++++++++++++++---- .../vercel-ai-sdk/src/app/api/chat/route.ts | 11 +++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts index 5a3721cda..b4a68f6da 100644 --- a/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts +++ b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/src/agent/agent.ts @@ -1,4 +1,4 @@ -import { AIMessage } from "@langchain/core/messages"; +import { AIMessage, type BaseMessage, isToolMessage } from "@langchain/core/messages"; import { type ServerTool, tool } from "@langchain/core/tools"; import { StateSchema } from "@langchain/langgraph"; import { ChatOpenAI } from "@langchain/openai"; @@ -34,6 +34,20 @@ function keepAppToolCallBlocks(block: unknown) { return typeof name === "string" && appToolNames.has(name); } +/** + * Cloud already holds prior turns via `conversation`. For each model step we + * only send the new items: the latest user message, or every ToolMessage from + * the current local tool round (parallel tools → multiple trailing ToolMessages). + */ +function cloudStepMessages(messages: BaseMessage[]): BaseMessage[] { + if (messages.length === 0) return messages; + const last = messages[messages.length - 1]!; + if (!isToolMessage(last)) return messages.slice(-1); + let start = messages.length - 1; + while (start > 0 && isToolMessage(messages[start - 1]!)) start -= 1; + return messages.slice(start); +} + // These are provider-executed tools. LangGraph sends their declarations to // OpenUI Cloud, while Cloud runs them and stores their outputs/artifacts. const cloudTools = [ @@ -75,9 +89,7 @@ const cloudConversation = createMiddleware({ const response = await handler({ ...request, model: cloudModel(model, conversationId), - // Cloud has the earlier turns. Within a LangGraph run this becomes the - // latest user message first, then each locally produced ToolMessage. - messages: request.messages.slice(-1), + messages: cloudStepMessages(request.messages), }); // Cloud has already executed its provider tools. Keep only app-owned diff --git a/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts index b4aba8fce..0a08d01ce 100644 --- a/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts +++ b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/src/app/api/chat/route.ts @@ -210,7 +210,16 @@ export async function POST(req: Request) { web_search: openai.tools.webSearch({}), }, stopWhen: stepCountIs(5), - prepareStep: ({ messages: stepMessages }) => ({ messages: stepMessages.slice(-1) }), + // Cloud holds history via `conversation`. Send only the new step items — + // the latest user/assistant turn, or every trailing tool result when the + // model called multiple tools in parallel. + prepareStep: ({ messages: stepMessages }) => { + const last = stepMessages.at(-1); + if (last?.role !== "tool") return { messages: stepMessages.slice(-1) }; + let start = stepMessages.length - 1; + while (start > 0 && stepMessages[start - 1]?.role === "tool") start -= 1; + return { messages: stepMessages.slice(start) }; + }, providerOptions: { openai: { conversation: threadId,