diff --git a/templates/openui-cloud/README.md b/templates/openui-cloud/README.md index 89864ab9f5..a75ee6fd9b 100644 --- a/templates/openui-cloud/README.md +++ b/templates/openui-cloud/README.md @@ -18,7 +18,7 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the You can start editing the page by modifying `src/app/api/chat/route.ts` and improving your agent by adding system prompts or tools. A LangGraph scaffold puts the implementation in -`src/agent/agent.ts` instead. +`src/agent.ts` instead. ## Deploy @@ -39,26 +39,34 @@ deploys. The Vercel AI SDK scaffold is a standard Next.js app: `streamText()` owns the agent loop and UIMessage stream, so the whole project can be deployed to Vercel. -In both variants, your framework executes application tools. OpenUI Cloud -provides managed conversation storage and executes its provider tools: web -search, image search, and configured MCP servers. +All variants use OpenUI Cloud's Chat Completions endpoint. Application tools +(such as the included weather tool) run in your server or agent framework. +Responses-only provider tools (`web_search`, `image_search`, and MCP declarations) +are not passed to Chat Completions; add application tools for those integrations. ## Conversation storage -OpenUI Cloud is the durable conversation store in every Cloud -variant. The browser connects directly through `useOpenuiCloudStorage()` with a -short-lived token from `/api/frontend-token`. For default, LangGraph, and -Vercel AI SDK routes, the `threadId` sent to `/api/chat` is the Cloud -conversation id, and the route appends each model turn to it with -`conversation: threadId` and `store: true`. The Eve overlay uses that same Cloud -thread store and maps each Cloud `threadId` to an Eve session cursor in the -browser; it does not use `/api/chat`. -Browser `localStorage` holds only the selected model (and, for Eve, the session -cursor), not conversation messages. - -The Vercel AI SDK route does not create a second store. Add a LangGraph -checkpointer separately only if the graph needs durable state, interrupts, or -resumable runs. +The browser connects to OpenUI Cloud through `useOpenuiCloudStorage()` with a +short-lived token from `/api/frontend-token`. The `threadId` is the Cloud +conversation id. + +The default and LangGraph routes send the full message history on each Chat +Completions request. After a successful turn, they call +`storeChatCompletionHistory` from `@openuidev/server/openai` with only the latest +user message and the new assistant/tool messages. This preserves reloadable +history without duplicating earlier turns. The default frontend uses +`openAIAdapter()` to read native Chat Completions SSE and loads persisted history +before each request, including tool results that are not part of that stream. +Chat Completions does not persist +conversations automatically through `conversation` or `store: true`. + +The Vercel AI SDK and Eve overlays already use Chat Completions, but do not yet +write their completed turns to Cloud storage. Eve uses its own HTTP session +protocol instead of `/api/chat`, with a session cursor in browser `localStorage`. +The selected model is also kept in `localStorage`. + +Add a LangGraph checkpointer separately if the graph needs durable execution +state, interrupts, or resumable runs. ## Switching Models @@ -72,6 +80,9 @@ list](https://models.dev/providers/openrouter/). ## SDK packages +- `@openuidev/server` — `storeChatCompletionHistory()` for saving new Chat + Completions turns to Cloud conversations. + - `@openuidev/lang-core` — `generateSystemPrompt({ cloud: true })` used by the `/api/chat` route. - `@openuidev/react-ui` — the chat UI runtime and component library diff --git a/templates/openui-cloud/overlays/langgraph/manifest.json b/templates/openui-cloud/overlays/langgraph/manifest.json index badcfa78b8..a84452057e 100644 --- a/templates/openui-cloud/overlays/langgraph/manifest.json +++ b/templates/openui-cloud/overlays/langgraph/manifest.json @@ -7,11 +7,10 @@ "@langchain/langgraph": "^1.4.9", "@langchain/openai": "^1.5.6", "langchain": "^1.5.5" - }, - "removeDependencies": ["openai"] + } }, "files": { "remove": ["src/lib/tool-loop.ts"] }, - "gettingStarted": "The generated LangGraph agent runs in-process inside the Next.js `/api/chat` route and uses OpenUI Cloud as its Responses provider. `{{packageManager}} run dev` starts the app. Share a preview with `{{packageManager}} run deploy`.\nAsk \"What's the weather in Berlin?\" to exercise the included LangGraph tool." + "gettingStarted": "The generated LangGraph agent runs in-process inside the Next.js `/api/chat` route and uses OpenUI Cloud as its Chat Completions provider. `{{packageManager}} run dev` starts the app. Share a preview with `{{packageManager}} run deploy`.\nAsk \"What's the weather in Berlin?\" to exercise the included LangGraph tool." } diff --git a/templates/openui-cloud/overlays/langgraph/package-lock.json b/templates/openui-cloud/overlays/langgraph/package-lock.json index 2ee2ed8fa7..0e0ea51fd2 100644 --- a/templates/openui-cloud/overlays/langgraph/package-lock.json +++ b/templates/openui-cloud/overlays/langgraph/package-lock.json @@ -15,9 +15,11 @@ "@openuidev/react-headless": "0.16.3", "@openuidev/react-lang": "0.3.0", "@openuidev/react-ui": "0.16.3", + "@openuidev/server": "0.1.0", "langchain": "^1.5.5", "lucide-react": "^0.575.0", "next": "^16.3.0", + "openai": "^6.49.0", "react": "19.2.3", "react-dom": "19.2.3", "zod": "^4.4.3", @@ -2372,6 +2374,43 @@ "@langchain/core": "^1.2.11" } }, + "node_modules/@langchain/openai/node_modules/openai": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.23.0.tgz", + "integrity": "sha512-0ecOXnFSMNWZq6cUBcTV6Lf93y+fm8BH/+QzFvpoP1UGNOE5pnytRa6HAPGCw+3IS9SIAW7rc1M8yyzN1PiBAQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/@langchain/protocol": { "version": "0.0.19", "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.19.tgz", @@ -2806,6 +2845,18 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@openuidev/server": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@openuidev/server/-/server-0.1.0.tgz", + "integrity": "sha512-Cm7nejELDHw0PoPlY67C4EW6b7DYx1ZnyCYyO5J6SIs6JjSBrY7rNzrnwB3DhAf4LmgO5sN/1WiD94BdZTNLPQ==", + "license": "MIT", + "dependencies": { + "@openuidev/lang-core": "^0.3.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@posthog/core": { "version": "1.55.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.2.tgz", @@ -9586,19 +9637,15 @@ } }, "node_modules/openai": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-7.23.0.tgz", - "integrity": "sha512-0ecOXnFSMNWZq6cUBcTV6Lf93y+fm8BH/+QzFvpoP1UGNOE5pnytRa6HAPGCw+3IS9SIAW7rc1M8yyzN1PiBAQ==", + "version": "6.49.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.49.0.tgz", + "integrity": "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==", "license": "Apache-2.0", - "engines": { - "node": ">=22.0.0" - }, "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", - "undici": ">=5 <9", - "ws": "^8.21.0", + "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { @@ -9611,9 +9658,6 @@ "@smithy/signature-v4": { "optional": true }, - "undici": { - "optional": true - }, "ws": { "optional": true }, diff --git a/templates/openui-cloud/overlays/langgraph/src/agent.ts b/templates/openui-cloud/overlays/langgraph/src/agent.ts index 4edabf32ed..d8daf3672a 100644 --- a/templates/openui-cloud/overlays/langgraph/src/agent.ts +++ b/templates/openui-cloud/overlays/langgraph/src/agent.ts @@ -1,9 +1,19 @@ -import { AIMessage, type BaseMessage, isToolMessage } from "@langchain/core/messages"; -import { type ServerTool, tool } from "@langchain/core/tools"; +import { + isAIMessage, + isHumanMessage, + isToolMessage, + type BaseMessage, +} from "@langchain/core/messages"; +import { tool } from "@langchain/core/tools"; import { StateSchema } from "@langchain/langgraph"; import { ChatOpenAI } from "@langchain/openai"; import { generateSystemPrompt } from "@openuidev/lang-core"; +import { storeChatCompletionHistory } from "@openuidev/server/openai"; import { createAgent, createMiddleware } from "langchain"; +import type { + ChatCompletionMessageParam, + ChatCompletionUserMessageParam, +} from "openai/resources/chat/completions"; import { z } from "zod"; import { requiredEnv } from "./lib/env"; import librarySpec from "./generated/spec.json"; @@ -15,115 +25,105 @@ const getWeather = tool( executeGetWeather(JSON.stringify({ location }), { signal: config.signal }), { name: "get_weather", - description: getWeatherTool.description, + description: getWeatherTool.function.description, schema: z.object({ - location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), + 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); -} - -/** - * 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. -const cloudTools = [ - { 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) { +function cloudModel(model: string) { return new ChatOpenAI({ model, apiKey: requiredEnv("THESYS_API_KEY"), streaming: true, - useResponsesApi: true, + useResponsesApi: false, configuration: { baseURL: "https://api.thesys.dev/v1/embed" }, - modelKwargs: { - store: true, - ...(conversationId ? { conversation: conversationId } : {}), - }, }); } +/** Convert only the current user turn and its tool rounds for Cloud storage. */ +function newTurnMessages( + messages: BaseMessage[], +): ChatCompletionMessageParam[] { + const start = messages.findLastIndex(isHumanMessage); + if (start < 0) return []; + return messages + .slice(start) + .flatMap((message): ChatCompletionMessageParam[] => { + if (isHumanMessage(message)) { + return [ + { + role: "user", + content: + message.content as ChatCompletionUserMessageParam["content"], + }, + ]; + } + const content = + typeof message.content === "string" + ? message.content + : message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join(""); + if (isAIMessage(message)) { + return [ + { + role: "assistant", + content: content || null, + ...(message.tool_calls?.length + ? { + tool_calls: message.tool_calls.map((call) => ({ + id: call.id!, + type: "function" as const, + function: { + name: call.name, + arguments: JSON.stringify(call.args), + }, + })), + } + : {}), + }, + ]; + } + if (isToolMessage(message)) { + return [{ role: "tool", tool_call_id: message.tool_call_id, content }]; + } + return []; + }); +} + 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({ + wrapModelCall: (request, handler) => + handler({ ...request, - model: cloudModel(model, conversationId), - messages: cloudStepMessages(request.messages), - }); - - // 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, + model: cloudModel(request.state.model), + }), + afterAgent: async (state) => { + await storeChatCompletionHistory({ + apiKey: requiredEnv("THESYS_API_KEY"), + conversationId: state.conversationId, + messages: newTurnMessages(state.messages), }); }, }); -/** - * A normal LangGraph agent, invoked in-process by the /api/chat route: - * LangGraph owns orchestration and local tool execution; OpenUI Cloud is - * the attached Responses provider. - */ +/** LangGraph owns the tool loop; OpenUI Cloud provides Chat Completions and storage. */ export const graph = createAgent({ model: cloudModel(DEFAULT_MODEL), - tools: [...cloudTools, ...appTools], + tools: [getWeather], systemPrompt: generateSystemPrompt({ cloud: true, library: librarySpec }), stateSchema: CloudAgentState, middleware: [cloudConversation], diff --git a/templates/openui-cloud/overlays/langgraph/src/app/api/chat/route.ts b/templates/openui-cloud/overlays/langgraph/src/app/api/chat/route.ts index 6501824aa5..3d0f1bd105 100644 --- a/templates/openui-cloud/overlays/langgraph/src/app/api/chat/route.ts +++ b/templates/openui-cloud/overlays/langgraph/src/app/api/chat/route.ts @@ -4,11 +4,11 @@ import { resolveRequestedModel } from "@/lib/models"; export const runtime = "nodejs"; /** - * Runs the LangGraph agent from src/agent/agent.ts in-process and returns its + * Runs the LangGraph agent from src/agent.ts in-process and returns its * native `messages`-mode SSE stream untransformed. The browser converts * outgoing messages with `langGraphMessageFormat` and parses the stream with - * `langGraphAdapter()`, so no conversion happens here. OpenUI Cloud stores - * prior turns, so only the newest message is sent as graph input. + * `langGraphAdapter()`. Chat Completions receives the full history; the agent + * saves only the new turn to OpenUI Cloud when it finishes. */ export async function POST(request: Request) { const { @@ -28,14 +28,35 @@ export async function POST(request: Request) { ); } + if (!Array.isArray(messages) || messages.length === 0) { + return Response.json( + { error: "messages must be a non-empty array" }, + { status: 400 }, + ); + } + + if (messages.at(-1)?.type !== "human") { + return Response.json( + { error: "messages must end with a human message" }, + { status: 400 }, + ); + } + const model = resolveRequestedModel(requestedModel); if (!model) { - return Response.json({ error: "model is not available in this agent" }, { status: 400 }); + return Response.json( + { error: "model is not available in this agent" }, + { status: 400 }, + ); } const stream = await graph.stream( - { messages: messages.slice(-1), conversationId: threadId, model }, - { streamMode: "messages", encoding: "text/event-stream", signal: request.signal }, + { messages, conversationId: threadId, model }, + { + streamMode: "messages", + encoding: "text/event-stream", + signal: request.signal, + }, ); return new Response(stream, { diff --git a/templates/openui-cloud/overlays/vercel-ai-sdk/package-lock.json b/templates/openui-cloud/overlays/vercel-ai-sdk/package-lock.json index 7c0f62dd22..f9fd22a776 100644 --- a/templates/openui-cloud/overlays/vercel-ai-sdk/package-lock.json +++ b/templates/openui-cloud/overlays/vercel-ai-sdk/package-lock.json @@ -13,6 +13,7 @@ "@openuidev/react-headless": "0.16.3", "@openuidev/react-lang": "0.3.0", "@openuidev/react-ui": "0.16.3", + "@openuidev/server": "0.1.0", "ai": "^7.0.111", "lucide-react": "^0.575.0", "next": "^16.3.0", @@ -2731,6 +2732,18 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@openuidev/server": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@openuidev/server/-/server-0.1.0.tgz", + "integrity": "sha512-Cm7nejELDHw0PoPlY67C4EW6b7DYx1ZnyCYyO5J6SIs6JjSBrY7rNzrnwB3DhAf4LmgO5sN/1WiD94BdZTNLPQ==", + "license": "MIT", + "dependencies": { + "@openuidev/lang-core": "^0.3.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@posthog/core": { "version": "1.55.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.2.tgz", diff --git a/templates/openui-cloud/overlays/vercel-ai-sdk/src/lib/tools.ts b/templates/openui-cloud/overlays/vercel-ai-sdk/src/lib/tools.ts index 6ce5c7fd21..dd88a995f7 100644 --- a/templates/openui-cloud/overlays/vercel-ai-sdk/src/lib/tools.ts +++ b/templates/openui-cloud/overlays/vercel-ai-sdk/src/lib/tools.ts @@ -4,9 +4,13 @@ import { z } from "zod"; export const appTools = { get_weather: tool({ - description: getWeatherTool.description, + description: getWeatherTool.function.description, inputSchema: z.object({ - location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), + location: z + .string() + .trim() + .min(1) + .describe("City or place name, e.g. Berlin."), }), execute: ({ location }, { abortSignal }) => executeGetWeather(JSON.stringify({ location }), { diff --git a/templates/openui-cloud/overlays/vercel-eve/agent/tools/get_weather.ts b/templates/openui-cloud/overlays/vercel-eve/agent/tools/get_weather.ts index 430926f13c..43c9385271 100644 --- a/templates/openui-cloud/overlays/vercel-eve/agent/tools/get_weather.ts +++ b/templates/openui-cloud/overlays/vercel-eve/agent/tools/get_weather.ts @@ -6,7 +6,7 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; export default defineTool({ - description: getWeatherTool.description, + description: getWeatherTool.function.description, inputSchema: z.object({ location: z.string().trim().min(1).describe("City or place name, e.g. Berlin."), }), diff --git a/templates/openui-cloud/overlays/vercel-eve/package-lock.json b/templates/openui-cloud/overlays/vercel-eve/package-lock.json index 99812e822e..6b314e0a00 100644 --- a/templates/openui-cloud/overlays/vercel-eve/package-lock.json +++ b/templates/openui-cloud/overlays/vercel-eve/package-lock.json @@ -13,6 +13,7 @@ "@openuidev/react-headless": "0.16.3", "@openuidev/react-lang": "0.3.0", "@openuidev/react-ui": "0.16.3", + "@openuidev/server": "0.1.0", "@vercel/connect": "0.2.2", "ai": "^7.0.82", "eve": "^0.52.2", @@ -2793,6 +2794,18 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@openuidev/server": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@openuidev/server/-/server-0.1.0.tgz", + "integrity": "sha512-Cm7nejELDHw0PoPlY67C4EW6b7DYx1ZnyCYyO5J6SIs6JjSBrY7rNzrnwB3DhAf4LmgO5sN/1WiD94BdZTNLPQ==", + "license": "MIT", + "dependencies": { + "@openuidev/lang-core": "^0.3.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@oxc-project/types": { "version": "0.151.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", @@ -4348,6 +4361,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4357,7 +4371,7 @@ "version": "19.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" diff --git a/templates/openui-cloud/package-lock.json b/templates/openui-cloud/package-lock.json index 7e0d7d1a9c..9f99076a54 100644 --- a/templates/openui-cloud/package-lock.json +++ b/templates/openui-cloud/package-lock.json @@ -12,6 +12,7 @@ "@openuidev/react-headless": "0.16.3", "@openuidev/react-lang": "0.3.0", "@openuidev/react-ui": "0.16.3", + "@openuidev/server": "0.1.0", "lucide-react": "^0.575.0", "next": "^16.3.0", "openai": "^6.49.0", @@ -2666,6 +2667,18 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@openuidev/server": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@openuidev/server/-/server-0.1.0.tgz", + "integrity": "sha512-Cm7nejELDHw0PoPlY67C4EW6b7DYx1ZnyCYyO5J6SIs6JjSBrY7rNzrnwB3DhAf4LmgO5sN/1WiD94BdZTNLPQ==", + "license": "MIT", + "dependencies": { + "@openuidev/lang-core": "^0.3.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@posthog/core": { "version": "1.55.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.55.2.tgz", diff --git a/templates/openui-cloud/package.json b/templates/openui-cloud/package.json index 8bb0c200bb..b88f04e77a 100644 --- a/templates/openui-cloud/package.json +++ b/templates/openui-cloud/package.json @@ -16,6 +16,7 @@ "@openuidev/react-headless": "0.16.3", "@openuidev/react-lang": "0.3.0", "@openuidev/react-ui": "0.16.3", + "@openuidev/server": "0.1.0", "lucide-react": "^0.575.0", "next": "^16.3.0", "openai": "^6.49.0", diff --git a/templates/openui-cloud/src/app/api/chat/route.ts b/templates/openui-cloud/src/app/api/chat/route.ts index e6f3b45ce2..f7cd6297a2 100644 --- a/templates/openui-cloud/src/app/api/chat/route.ts +++ b/templates/openui-cloud/src/app/api/chat/route.ts @@ -6,18 +6,14 @@ import { executeGetWeather, getWeatherTool } from "@/lib/tools/get-weather"; import { generateSystemPrompt } from "@openuidev/lang-core"; import { NextResponse } from "next/server"; import OpenAI from "openai"; +import { storeChatCompletionHistory } from "@openuidev/server/openai"; import type { - ResponseCreateParamsNonStreaming, - ResponseInputItem, - Tool, -} from "openai/resources/responses/responses"; + ChatCompletionCreateParamsNonStreaming, + ChatCompletionMessageParam, + ChatCompletionChunk, +} from "openai/resources/chat/completions"; -/** - * Generation plane: browser → this route → OpenUI Cloud's Responses API, - * proxying the SSE stream back for `openAIResponsesAdapter` to parse. - * Cloud tools (search / MCP) run inside Cloud; app-owned - * `type: "function"` tools run here via `runFunctionToolLoop`. - */ +/** Chat Completions generation with app-owned tools and explicit Cloud storage. */ export async function POST(req: Request) { const { threadId, @@ -25,17 +21,19 @@ export async function POST(req: Request) { model: requestedModel, } = (await req.json()) as { threadId?: string; - messages?: ResponseInputItem[]; + messages?: ChatCompletionMessageParam[]; model?: unknown; }; - if (!threadId) return badRequest("threadId is required — create the conversation first"); + 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 ResponseInputItem[]"); + return badRequest( + "messages must be a non-empty ChatCompletionMessageParam[]", + ); } - // History is stored server-side (conversation + store:true) - // forward only the latest message upstream. - const input = messages.slice(-1); + if (messages.at(-1)?.role !== "user") + return badRequest("messages must end with a user message"); const model = resolveRequestedModel(requestedModel); if (!model) return badRequest("model is not available in this agent"); @@ -46,31 +44,27 @@ export async function POST(req: Request) { // App-owned function tools — the loop runs only the names declared here. const functionTools = { - [getWeatherTool.name]: executeGetWeather, + [getWeatherTool.function.name]: executeGetWeather, }; - const createParams: ResponseCreateParamsNonStreaming = { + const createParams: ChatCompletionCreateParamsNonStreaming = { model, - conversation: threadId, // store:true persists to the conversation - input, - store: true, - tools: [ - { type: "web_search" }, - // image_search is a Cloud extension of the Responses tool union. - { type: "image_search" } as unknown as Tool, - getWeatherTool, - // Remote MCP servers run inside OpenUI Cloud, e.g.: - // { type: "mcp", server_label: "deepwiki", server_url: "https://mcp.deepwiki.com/mcp" }, + messages: [ + { + role: "system", + content: generateSystemPrompt({ cloud: true, library: librarySpec }), + }, + ...messages, ], - instructions: generateSystemPrompt({ cloud: true, library: librarySpec }), + tools: [getWeatherTool], }; - let stream: AsyncIterable>; + let stream: AsyncIterable; try { - stream = (await client.responses.create( + stream = await client.chat.completions.create( { ...createParams, stream: true }, { signal: req.signal }, // propagate browser aborts (stop button / tab close) - )) as unknown as AsyncIterable>; + ); } catch (err) { // Propagate the upstream message and status; the chat store surfaces it. const e = err as { status?: number; error?: unknown; message?: string }; @@ -80,15 +74,19 @@ export async function POST(req: Request) { ); } - // Re-emit SDK events as SSE, executing function tools between model turns. + // Forward native Chat Completions chunks as SSE for openAIAdapter(). const encoder = new TextEncoder(); + let open = true; const body = new ReadableStream({ async start(controller) { - const enqueue = (event: Record) => { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + const enqueue = (event: ChatCompletionChunk) => { + if (open) + controller.enqueue( + encoder.encode(`data: ${JSON.stringify(event)}\n\n`), + ); }; try { - await runFunctionToolLoop({ + const turn = await runFunctionToolLoop({ client, createParams, firstStream: stream, @@ -96,15 +94,22 @@ export async function POST(req: Request) { enqueue, signal: req.signal, }); - } catch (err) { - enqueue({ - type: "error", - message: err instanceof Error ? err.message : String(err), + await storeChatCompletionHistory({ + apiKey: requiredEnv("THESYS_API_KEY"), + conversationId: threadId, + messages: [...messages.slice(-1), ...turn], }); - } finally { - controller.close(); + if (open) { + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + } catch (err) { + if (open) controller.error(err); } }, + cancel() { + open = false; + }, }); return new Response(body, { diff --git a/templates/openui-cloud/src/components/cloud-chat.tsx b/templates/openui-cloud/src/components/cloud-chat.tsx index f695f40936..931e96347a 100644 --- a/templates/openui-cloud/src/components/cloud-chat.tsx +++ b/templates/openui-cloud/src/components/cloud-chat.tsx @@ -8,8 +8,8 @@ import { ModelSwitcher, fetchLLM, openuiLibrary, - openAIConversationMessageFormat, - openAIResponsesAdapter, + openAIMessageFormat, + openAIAdapter, useOpenuiCloudStorage, useSystemThemeMode, } from "@openuidev/react-ui"; @@ -17,19 +17,33 @@ import { export default function CloudChat() { const mode = useSystemThemeMode(); const [selectedModel, setSelectedModel] = usePersistedModel(); - const llm = fetchLLM({ - url: "/api/chat", - streamAdapter: openAIResponsesAdapter(), - messageFormat: openAIConversationMessageFormat, - body: { model: selectedModel }, - }); - const storage = useOpenuiCloudStorage({ token: "/api/frontend-token", apiBaseUrl: "https://api.thesys.dev", - features: { artifact: false } + features: { artifact: false }, }); + const transport = fetchLLM({ + url: "/api/chat", + streamAdapter: openAIAdapter(), + messageFormat: openAIMessageFormat, + body: { model: selectedModel }, + }); + + const llm = { + ...transport, + send: async (params: Parameters[0]) => { + // Completion chunks have no tool-result events. Replay persisted history + // so earlier tool calls always include the results saved by our server. + const history = await storage.thread.getMessages(params.threadId); + params.signal.throwIfAborted(); + return transport.send({ + ...params, + messages: [...history, ...params.messages.slice(-1)], + }); + }, + }; + const logoPath = mode === "dark" ? OPENUI_LOGOS.DARK : OPENUI_LOGOS.LIGHT; return ( diff --git a/templates/openui-cloud/src/lib/tool-loop.ts b/templates/openui-cloud/src/lib/tool-loop.ts index 774d544489..5060597039 100644 --- a/templates/openui-cloud/src/lib/tool-loop.ts +++ b/templates/openui-cloud/src/lib/tool-loop.ts @@ -1,180 +1,109 @@ import type OpenAI from "openai"; import type { - ResponseCreateParamsNonStreaming, - ResponseInputItem, -} from "openai/resources/responses/responses"; - -/** - * Function-tool execution loop for the OpenUI Cloud Responses API. - * - * OpenUI Cloud executes its own tools (web_search, image_search, MCP) - * server-side, but `type: "function"` tools you declare are executed by - * YOUR server: the model emits a `function_call`, you run it, post the - * `function_call_output` back, and the model continues — possibly calling more - * tools — until it produces the final answer. - * - * Two rules make this safe alongside Cloud's server-side tools, and both are - * enforced here rather than left to the caller: - * - * 1. Execute ONLY calls whose `name` you declared (the keys of `tools`). - * Cloud may stream its own tools as real-named `function_call` items. Those - * are already executed server-side and must never be run or answered again. - * 2. Skip any call whose `call_id` already received a `function_call_output` - * on the same stream. The API never streams an output for a call it wants - * the client to execute, so an output's presence means "already settled". - * - * The loop mirrors the openai SDK's `runTools` invariant: the round cap limits - * additional model turns, never settlement — the last allowed round posts its - * outputs with `tool_choice: "none"`, and calls that a non-enforcing server - * still lets through are settled in one final forward-only turn, so a stored - * conversation is never left holding an unanswered `function_call`. Two - * deliberate deviations from - * `runTools`, both forced by server-side tools + stored conversations: - * undeclared names are ignored rather than answered with an "invalid tool" - * message (rule 1), and executor throws become error outputs rather than - * aborting the run (a mid-run abort would strand calls in the conversation). - */ + ChatCompletionChunk, + ChatCompletionCreateParamsNonStreaming, + ChatCompletionMessageParam, + ChatCompletionMessageFunctionToolCall, +} from "openai/resources/chat/completions"; export type FunctionToolExecutor = ( argsJson: string, ctx: { callId: string; signal?: AbortSignal }, ) => Promise; -export interface RunFunctionToolLoopOptions { +interface RunFunctionToolLoopOptions { client: OpenAI; - /** The params of the original request; reused verbatim for continuations. */ - createParams: ResponseCreateParamsNonStreaming; - /** The already-open stream of the first response. */ - firstStream: AsyncIterable>; - /** name → executor. The keys are the ONLY tool names this loop will run. */ + createParams: ChatCompletionCreateParamsNonStreaming; + firstStream: AsyncIterable; tools: Record; - /** Receives every stream event (forward these to the browser as SSE). */ - enqueue: (event: Record) => void; - /** Propagates browser aborts into executors and continuation requests. */ + enqueue: (chunk: ChatCompletionChunk) => void; signal?: AbortSignal; - /** Cap on model round-trips after tool results (default 5). */ maxRounds?: number; } -interface PendingCall { - callId: string; - name: string; - argsJson: string; -} +/** Replay history between tool rounds; return only new messages for Cloud storage. */ +export async function runFunctionToolLoop({ + client, + createParams, + firstStream, + tools, + enqueue, + signal, + maxRounds = 5, +}: RunFunctionToolLoopOptions): Promise { + const turn: ChatCompletionMessageParam[] = []; + let stream = firstStream; -/** - * Drive the stream to completion, executing declared function tools between - * model turns. Resolves when the model finishes without requesting any of the - * caller's tools; if `maxRounds` is reached, the final round still posts its - * outputs but pins `tool_choice: "none"` so the model must answer in text. - */ -export async function runFunctionToolLoop(options: RunFunctionToolLoopOptions): Promise { - const { client, createParams, tools, enqueue, signal, maxRounds = 5 } = options; + for (let round = 0; ; round++) { + const calls = new Map(); + let content = ""; - // Run every pending call and surface each result to the browser; a throwing - // executor settles as an error output rather than aborting the run. - const executeCalls = async (calls: PendingCall[]): Promise => { - const outputs: ResponseInputItem[] = []; - for (const call of calls) { + for await (const chunk of stream) { + signal?.throwIfAborted(); + enqueue(chunk); + const delta = chunk.choices[0]?.delta; + if (delta?.content) { + content += delta.content; + } + for (const part of delta?.tool_calls ?? []) { + let call = calls.get(part.index); + if (!call) { + call = { + id: "", + type: "function", + function: { name: "", arguments: "" }, + }; + calls.set(part.index, call); + } + if (part.id) call.id = part.id; + if (part.function?.name) call.function.name += part.function.name; + if (part.function?.arguments) + call.function.arguments += part.function.arguments; + } + } + + const toolCalls = [...calls.values()]; + for (const call of toolCalls) { + if (!call.id || !call.function.name) + throw new Error("Incomplete tool call from upstream"); + } + turn.push({ + role: "assistant", + content: content || null, + ...(toolCalls.length ? { tool_calls: toolCalls } : {}), + }); + if (toolCalls.length === 0) return turn; + if (round >= maxRounds) + throw new Error("Model exceeded the tool-call round limit"); + + for (const call of toolCalls) { + signal?.throwIfAborted(); let output: string; try { - output = await tools[call.name]!(call.argsJson, { callId: call.callId, signal }); - } catch (err) { - output = JSON.stringify({ error: err instanceof Error ? err.message : String(err) }); + if (!Object.hasOwn(tools, call.function.name)) { + throw new Error(`Unknown tool: ${call.function.name}`); + } + output = await tools[call.function.name]!(call.function.arguments, { + callId: call.id, + signal, + }); + } catch (error) { + signal?.throwIfAborted(); + output = JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }); } - const item = { type: "function_call_output" as const, call_id: call.callId, output }; - outputs.push(item); - enqueue({ - type: "response.output_item.added", - item: { ...item, id: `fc_out_${call.callId}` }, - }); + turn.push({ role: "tool", tool_call_id: call.id, content: output }); } - return outputs; - }; - const continueWith = (outputs: ResponseInputItem[], settleOnly: boolean) => - client.responses.create( + stream = await client.chat.completions.create( { ...createParams, - input: outputs, + messages: [...createParams.messages, ...turn], stream: true, - ...(settleOnly ? { tool_choice: "none" as const } : {}), + ...(round >= maxRounds - 1 ? { tool_choice: "none" as const } : {}), }, { signal }, - ) as unknown as Promise>>; - - let pending = await consumeStream(options.firstStream, tools, enqueue); - - for (let round = 0; pending.length > 0; round++) { - // Last allowed round: settlement still happens, but the model may not - // request more tools. - const settleOnly = round >= maxRounds - 1; - const stream = await continueWith(await executeCalls(pending), settleOnly); - pending = await consumeStream(stream, tools, enqueue); - if (settleOnly) break; + ); } - - // Non-empty only when the settle round produced NEW calls — i.e. the server - // did not enforce tool_choice:"none" (observed with some models). Settle - // them once more, forward-only: a stored conversation must never be left - // holding an unanswered function_call. - if (pending.length > 0) { - const stream = await continueWith(await executeCalls(pending), true); - for await (const event of stream) enqueue(event); - } -} - -/** - * Forward every event and collect the declared-tool calls that the stream - * leaves unanswered. Arguments may arrive on the added item, as deltas, or on - * the done item — all three are handled, last write wins. - */ -async function consumeStream( - stream: AsyncIterable>, - tools: Record, - enqueue: (event: Record) => void, -): Promise { - const callsByItemId = new Map(); - const calls: PendingCall[] = []; - const answeredCallIds = new Set(); - - for await (const event of stream) { - enqueue(event); - - const type = event.type; - if (type === "response.output_item.added" || type === "response.output_item.done") { - const item = event.item as - | { type?: string; id?: string; call_id?: string; name?: string; arguments?: string } - | undefined; - if (item?.type === "function_call_output" && item.call_id) { - answeredCallIds.add(item.call_id); - } else if (item?.type === "function_call" && item.call_id && item.name) { - const known = - (item.id ? callsByItemId.get(item.id) : undefined) ?? callsByItemId.get(item.call_id); - if (known) { - if (item.arguments != null) known.argsJson = item.arguments; - } else if (Object.hasOwn(tools, item.name)) { - // Rule 1: track only declared tools — everything else (including - // Cloud-internal thesys_* calls) is passed through untouched. - const call: PendingCall = { - callId: item.call_id, - name: item.name, - argsJson: item.arguments ?? "", - }; - calls.push(call); - if (item.id) callsByItemId.set(item.id, call); - callsByItemId.set(item.call_id, call); - } - } - } else if (type === "response.function_call_arguments.delta") { - const call = callsByItemId.get(event.item_id as string); - if (call && typeof event.delta === "string") call.argsJson += event.delta; - } else if (type === "response.function_call_arguments.done") { - const call = callsByItemId.get(event.item_id as string); - if (call && typeof event.arguments === "string") call.argsJson = event.arguments; - } - } - - // Rule 2: a call that already has an output on this stream is settled. - return calls.filter((call) => !answeredCallIds.has(call.callId)); } diff --git a/templates/openui-cloud/src/lib/tools/get-weather.ts b/templates/openui-cloud/src/lib/tools/get-weather.ts index cc859075fd..6a48866430 100644 --- a/templates/openui-cloud/src/lib/tools/get-weather.ts +++ b/templates/openui-cloud/src/lib/tools/get-weather.ts @@ -6,25 +6,27 @@ * wired together through `runFunctionToolLoop` in the chat route. */ -/** OpenAI Responses `type: "function"` declaration sent to the model. */ +/** OpenAI Chat Completions `type: "function"` declaration sent to the model. */ export const getWeatherTool = { type: "function" as const, - name: "get_weather", - description: - "Get the current weather for a city or place name. Use whenever the user " + - "asks about weather, temperature, rain, or what to wear.", - parameters: { - type: "object", - properties: { - location: { - type: "string", - description: "City or place name, e.g. 'Berlin' or 'San Francisco'.", + function: { + name: "get_weather", + description: + "Get the current weather for a city or place name. Use whenever the user " + + "asks about weather, temperature, rain, or what to wear.", + parameters: { + type: "object", + properties: { + location: { + type: "string", + description: "City or place name, e.g. 'Berlin' or 'San Francisco'.", + }, }, + required: ["location"], + additionalProperties: false, }, - required: ["location"], - additionalProperties: false, + strict: false, }, - strict: false, }; // WMO weather codes (https://open-meteo.com/en/docs), collapsed to families. @@ -59,20 +61,36 @@ export async function executeGetWeather( 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 (await fetch(geoUrl, { signal: ctx.signal })).json()) as { - results?: Array<{ name: string; country?: string; latitude: number; longitude: number }>; + const geo = (await ( + await fetch(geoUrl, { signal: ctx.signal }) + ).json()) as { + results?: Array<{ + name: string; + country?: string; + latitude: number; + longitude: number; + }>; }; const place = geo.results?.[0]; - if (!place) return JSON.stringify({ error: `No place found for "${location}"` }); + if (!place) + return JSON.stringify({ error: `No place found for "${location}"` }); const wxUrl = new URL("https://api.open-meteo.com/v1/forecast"); wxUrl.searchParams.set("latitude", String(place.latitude)); wxUrl.searchParams.set("longitude", String(place.longitude)); - wxUrl.searchParams.set("current", "temperature_2m,weather_code,wind_speed_10m"); + wxUrl.searchParams.set( + "current", + "temperature_2m,weather_code,wind_speed_10m", + ); const wx = (await (await fetch(wxUrl, { signal: ctx.signal })).json()) as { - current?: { temperature_2m: number; weather_code: number; wind_speed_10m: number }; + current?: { + temperature_2m: number; + weather_code: number; + wind_speed_10m: number; + }; }; - if (!wx.current) return JSON.stringify({ error: "No weather data returned" }); + if (!wx.current) + return JSON.stringify({ error: "No weather data returned" }); return JSON.stringify({ place: `${place.name}${place.country ? `, ${place.country}` : ""}`,