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..aa16abe5c --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/langgraph/manifest.json @@ -0,0 +1,24 @@ +{ + "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" + } + }, + "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..467b58fa3 --- /dev/null +++ b/packages/openui-cli/src/templates/openui-cloud/backends/vercel-ai-sdk/manifest.json @@ -0,0 +1,13 @@ +{ + "packageJson": { + "dependencies": { + "@ai-sdk/openai": "^3.0.91", + "ai": "^6.0.246", + "zod": "^4.4.3" + } + }, + "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/