diff --git a/docs/content/docs/agent/getting-started/quickstart.mdx b/docs/content/docs/agent/getting-started/quickstart.mdx index 8ef1f7186..e840b72fc 100644 --- a/docs/content/docs/agent/getting-started/quickstart.mdx +++ b/docs/content/docs/agent/getting-started/quickstart.mdx @@ -10,10 +10,10 @@ The CLI scaffolds a complete Next.js app: a streaming chat with a sidebar, threa Run the create command and answer the prompts. One prompt asks **OpenUI Cloud or self-hosted?** Your choice decides which backend the scaffold wires up. - ```bash npx @openuidev/cli@latest create ``` - ```bash pnpm dlx @openuidev/cli@latest create ``` - ```bash yarn dlx @openuidev/cli@latest create ``` - ```bash bunx @openuidev/cli@latest create ``` + ```npx @openuidev/cli@latest create ``` + ```pnpm dlx @openuidev/cli@latest create ``` + ```yarn dlx @openuidev/cli@latest create ``` + ```bunx @openuidev/cli@latest create ``` When it finishes, move into the project: @@ -27,7 +27,7 @@ cd my-agent -Connect the scaffold to OpenUI Cloud. Generate an API key in the [Thesys console](https://console.thesys.dev/keys) and add it to `.env.local`: +The CLI will prompt you to sign in to Thesys and generate an API Key in your browser, or you can generate your own on the [Thesys console](https://console.thesys.dev/keys) and add it to `.env.local`: ```bash THESYS_API_KEY=sk-th-your-key-here diff --git a/examples/openui-cloud/README.md b/examples/openui-cloud/README.md index 66d00e370..e2081d0d1 100644 --- a/examples/openui-cloud/README.md +++ b/examples/openui-cloud/README.md @@ -1,61 +1,26 @@ # openui-cloud — OpenUI Cloud integration example -A Next.js app showing how an external app integrates with OpenUI Cloud using its -**two-plane** model: +A minimal **Next.js (App Router)** app that hosts OpenUI Cloud: a generative-UI chat that streams and persists them to your OpenUI Cloud org. -- **Generation plane (master key, server-side):** `/api/chat` forwards - `{ threadId, input }` to `POST /v1/embed/responses` with the org master key - (`conversation: threadId`, `store:true`, `stream:true`, `tools:[artifactTool()]`, - `instructions: createResponsesInstructions()`) and pipes the SSE stream back - unchanged. `/api/frontend-token` proxies `POST /v1/frontend-tokens` so the - browser gets a short-lived `fct_` token **without ever seeing the master key**. -- **Read/edit plane (fct_, browser-direct):** the client page wires - `` - against a `ChatStorage` from the **`useOpenuiCloudStorage()`** hook (browser → - `/v1/conversations` + `/v1/artifacts` via the `x-thesys-frontend-token` header, - single-flight refresh + 401 retry) and the presentation/report artifact - renderers (`artifactRenderers` / `artifactCategories` from `@openuidev/thesys`). +## Prerequisites -## Local dependency wiring (do this first) +- Node + `pnpm`. +- An **OpenUI Cloud org master key** (`THESYS_API_KEY`). -`@openuidev/thesys` is **not published** — this app consumes it from a sibling -**genui-sdk** checkout via a vendored tarball, and `@openuidev/thesys-server` via a -vendored build. **Both `vendor/` artifacts are gitignored**, so after cloning you -must produce them yourself. - -Prereq: `genui-sdk` cloned as a **sibling of `openui`** (so `../../../genui-sdk` -resolves from this dir), on branch **`ap-server`**. +## Setup ```bash -# 1. Build the SDK packages in genui-sdk (on ap-server). -cd /path/to/genui-sdk -git checkout ap-server && git pull && pnpm install -pnpm --filter @openuidev/thesys build -pnpm --filter @openuidev/thesys-server build - -# 2. Vendor both into openui-cloud. -VENDOR=/path/to/openui/examples/openui-cloud/vendor -( cd packages/c1 && pnpm pack --pack-destination "$VENDOR" ) # → openuidev-thesys-0.1.0.tgz -mkdir -p "$VENDOR/c1-server" && cp packages/c1-server/dist/index.* "$VENDOR/c1-server/" - -# 3. Install this app (force — the tgz filename is stable, so pnpm caches it). -cd /path/to/openui/examples/openui-cloud -pnpm install --force +pnpm install +cp .env.example .env.local # then fill THESYS_API_KEY ``` -Re-run these whenever you change `c1` / `c1-server` in genui-sdk. `next.config.ts` -aliases `@openuidev/thesys-server` → `vendor/c1-server/index.mjs` (Turbopack won't -follow the cross-repo symlink) and stubs `lucide-react/dynamic`. +| Var | Required | Default | Purpose | +| ---------------- | -------- | ----------------------------- | ---------------------------------------------------------------- | +| `THESYS_API_KEY` | yes | — | Org master key. **Server-side only**; never reaches the browser. | +| `OPENUI_MODEL` | no | `anthropic/claude-sonnet-4.6` | Bare `provider/model` id for generation. | +| `DEMO_USER_ID` | no | `demo-user` | End-user identity stamped into the frontend token. | -## Setup (env) - -```bash -cp .env.example .env.local # fill THESYS_API_KEY and point the base URLs at your API -``` - -Required env (see `.env.example`): `THESYS_API_KEY`, `OPENUI_CLOUD_BASE_URL`, -`OPENUI_MODEL` (bare `provider/model`, e.g. `openai/gpt-5`), `DEMO_USER_ID`, -`NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL`. +`.env.local` is gitignored. Restart `pnpm dev` after editing env. ## Run @@ -63,22 +28,15 @@ Required env (see `.env.example`): `THESYS_API_KEY`, `OPENUI_CLOUD_BASE_URL`, pnpm dev # http://localhost:3300 ``` -Point `OPENUI_CLOUD_BASE_URL` / `NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL` at your OpenUI -Cloud API origin. +Open the app, pick a starter prompt ("Quarterly deck" / "Market report"), and watch the artifact render live as it streams. -## Typecheck +## Scripts ```bash -pnpm exec tsc --noEmit +pnpm dev # dev server on :3300 +pnpm build # production build (output: standalone) +pnpm start # serve the production build on :3300 +pnpm typecheck # tsc --noEmit +pnpm lint # eslint +pnpm test # vitest run ``` - -## SDK packages - -- `@openuidev/thesys-server` — the server SDK (`artifactTool`, - `createResponsesInstructions`) used by the `/api/chat` route. -- `@openuidev/thesys` — the React SDK: `useOpenuiCloudStorage` (browser storage - hook), `artifactRenderers` / `artifactCategories`, `chatLibrary`, and the - `Presentation` / `Report` viewers, used by the client page. **Not published** — - vendored from genui-sdk (see "Local dependency wiring"). -- `@openuidev/react-headless` / `@openuidev/react-ui` — the chat UI runtime - (`AgentInterface`, storage/stream contracts, `defineArtifactRenderer`). diff --git a/examples/openui-cloud/package.json b/examples/openui-cloud/package.json index c9c419042..e3a32309f 100644 --- a/examples/openui-cloud/package.json +++ b/examples/openui-cloud/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "@floating-ui/react-dom": "2.1.3", + "@openuidev/thesys": "latest", + "@openuidev/thesys-server": "latest", "@openuidev/lang-core": "workspace:*", "@openuidev/react-headless": "workspace:*", "@openuidev/react-lang": "workspace:*", diff --git a/examples/openui-cloud/src/app/api/chat/route.ts b/examples/openui-cloud/src/app/api/chat/route.ts index 48cf357f9..01fd4ef93 100644 --- a/examples/openui-cloud/src/app/api/chat/route.ts +++ b/examples/openui-cloud/src/app/api/chat/route.ts @@ -1,5 +1,6 @@ -import { envOr, openuiCloudBaseUrl, requiredEnv } from "@/lib/env"; +import { envOr, requiredEnv } from "@/lib/env"; import { artifactTool, createResponsesInstructions } from "@openuidev/thesys-server"; +import OpenAI from "openai"; import type { ResponseInputItem } from "openai/resources/responses/responses"; /** @@ -15,9 +16,6 @@ export async function POST(req: Request) { input?: ResponseInputItem[]; }; - // The conversation must already exist — the API replays history from it and - // stamps ownership on persist. The chat store creates the thread before the - // first send. if (!threadId) { return Response.json( { error: { message: "threadId is required — create the conversation first" } }, @@ -30,39 +28,68 @@ export async function POST(req: Request) { { status: 400 }, ); } - console.log("artifactTool", artifactTool()); - const upstream = await fetch(`${openuiCloudBaseUrl()}/v1/embed/responses`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${requiredEnv("THESYS_API_KEY")}`, - }, - body: JSON.stringify({ - // A bare provider/model id (versioned managed ids are mutually - // exclusive with the instructions config block). Configurable via - // OPENUI_MODEL (.env.local); defaults to openai/gpt-5. - model: envOr("OPENUI_MODEL", "openai/gpt-5"), - conversation: threadId, - input, - stream: true, - store: true, - tools: [artifactTool()], - instructions: createResponsesInstructions(), - }), - signal: req.signal, // propagate browser aborts (stop button / tab close) + + const client = new OpenAI({ + // responses.create() POSTs to `${baseURL}/responses` → /v1/embed/responses. + baseURL: `https://api.thesys.dev/v1/embed`, + apiKey: requiredEnv("THESYS_API_KEY"), // sent as Authorization: Bearer … }); - if (!upstream.ok || !upstream.body) { - // Forward the upstream error body verbatim (OpenAI-shaped JSON). - const detail = await upstream.text().catch(() => ""); - return new Response(detail || JSON.stringify({ error: { message: "upstream error" } }), { - status: upstream.status || 502, - headers: { "Content-Type": "application/json" }, - }); + let stream: AsyncIterable>; + try { + stream = (await client.responses.create( + { + model: envOr("OPENUI_MODEL", "anthropic/claude-sonnet-4.6"), + conversation: threadId, // store:true persists to the conversation + input, + stream: true, + store: true, + // `artifacts` makes each entry carry library_version:'0.1.0' → openui-lang. + // Bare artifactTool() would fall back to the legacy XML model. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools: [ + artifactTool({ artifacts: ["slides", "report"] }), + { + type: "web_search", + }, + { + type: "image_search", + }, + ], + instructions: createResponsesInstructions(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + { signal: req.signal }, // propagate browser aborts (stop button / tab close) + )) as unknown as AsyncIterable>; + } catch (err) { + // The SDK surfaces upstream HTTP errors (e.g. 403) as APIError. + const e = err as { status?: number; error?: unknown; message?: string }; + return Response.json( + { error: e.error ?? { message: e.message ?? "upstream error" } }, + { status: e.status ?? 502 }, + ); } - // Pipe the SSE byte stream through untouched. - return new Response(upstream.body, { + // Re-emit each SDK event as SSE for the browser adapter. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + async start(controller) { + try { + for await (const event of stream) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: "error", message })}\n\n`), + ); + } finally { + controller.close(); + } + }, + }); + + return new Response(body, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", diff --git a/examples/openui-cloud/src/app/api/frontend-token/route.ts b/examples/openui-cloud/src/app/api/frontend-token/route.ts index 8f8e71918..41f57fe81 100644 --- a/examples/openui-cloud/src/app/api/frontend-token/route.ts +++ b/examples/openui-cloud/src/app/api/frontend-token/route.ts @@ -1,16 +1,7 @@ -import { envOr, openuiCloudBaseUrl, requiredEnv } from "@/lib/env"; +import { envOr, requiredEnv } from "@/lib/env"; -/** - * Read-plane credential mint: proxies the OpenUI Cloud POST /v1/frontend-tokens - * (master-key plane) and returns ONLY { token, expires_at }. - * - * - The master key never reaches the browser (server env; the response is - * field-picked, never passed through). - * - user_id comes from server config — the browser must not choose its own - * identity. - */ export async function POST() { - const upstream = await fetch(`${openuiCloudBaseUrl()}/v1/frontend-tokens`, { + const upstream = await fetch(`https://api.thesys.dev/v1/frontend-tokens`, { method: "POST", headers: { "Content-Type": "application/json", @@ -20,13 +11,11 @@ export async function POST() { }); if (!upstream.ok) { - // Never forward upstream auth-error bodies (they can embed key fragments). - console.error( - "[frontend-token] mint failed:", - upstream.status, - await upstream.text().catch(() => ""), - ); - return Response.json({ error: { message: "token mint failed" } }, { status: 502 }); + const errText = await upstream + .text() + .catch(() => "There was an error in the response from the upstream service."); + console.error("[frontend-token] mint failed:", upstream.status, errText); + return Response.json({ error: { message: errText } }, { status: 502 }); } const { token, expires_at } = (await upstream.json()) as { token: string; expires_at: number }; diff --git a/examples/openui-cloud/src/app/page.tsx b/examples/openui-cloud/src/app/page.tsx index dc818886a..9ee8fd013 100644 --- a/examples/openui-cloud/src/app/page.tsx +++ b/examples/openui-cloud/src/app/page.tsx @@ -56,7 +56,7 @@ export default function Page() { // refreshes it and injects x-thesys-frontend-token on every /v1 call. token: "/api/frontend-token", // Env-driven so a local stack can be targeted; defaults to prod when unset. - apiBaseUrl: process.env.NEXT_PUBLIC_OPENUI_CLOUD_BASE_URL, + apiBaseUrl: "https://api.thesys.dev", features: { artifact: true }, }); diff --git a/examples/openui-cloud/src/lib/env.ts b/examples/openui-cloud/src/lib/env.ts index c1878281c..815c584ee 100644 --- a/examples/openui-cloud/src/lib/env.ts +++ b/examples/openui-cloud/src/lib/env.ts @@ -10,12 +10,3 @@ export function requiredEnv(name: string): string { export function envOr(name: string, fallback: string): string { return process.env[name] || fallback; } - -/** - * OpenUI Cloud API origin (master-key plane: /v1/embed/responses, /v1/frontend-tokens). - * Read at request time (per this file's convention) and env-driven so a local stack can be - * targeted via `OPENUI_CLOUD_BASE_URL`; defaults to production. - */ -export function openuiCloudBaseUrl(): string { - return envOr("OPENUI_CLOUD_BASE_URL", "https://api.thesys.dev"); -} diff --git a/examples/openui-cloud/src/lib/thesys/artifactStorage.ts b/examples/openui-cloud/src/lib/thesys/artifactStorage.ts deleted file mode 100644 index 81c1a1b5e..000000000 --- a/examples/openui-cloud/src/lib/thesys/artifactStorage.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { - Artifact, - ArtifactListParams, - ArtifactStorage, - ArtifactSummary, -} from "@openuidev/react-headless"; -import { cloudRequest, nextCursorOf, type CloudArtifact, type CloudListEnvelope } from "./wire"; - -export interface CloudArtifactStorageOptions { - /** OpenUI Cloud API origin, e.g. "http://localhost:3102". */ - baseUrl: string; - /** The token-injecting fetch from createFctFetch. */ - fetch: typeof fetch; - /** Default page size when the caller passes no limit. */ - pageLimit?: number; -} - -function toSummary(artifact: CloudArtifact): ArtifactSummary { - return { - id: artifact.id, - title: artifact.name ?? artifact.id, - type: artifact.kind, - threadId: artifact.conversation_id, - updatedAt: (artifact.updated_at ?? artifact.created_at) * 1000, - }; -} - - -export function cloudArtifactStorage({ - baseUrl, - fetch: fetchImpl, - pageLimit = 100, -}: CloudArtifactStorageOptions): ArtifactStorage { - const request = cloudRequest(fetchImpl, baseUrl); - - return { - /** GET /v1/artifacts?[name=][kind=…]&limit[&after=]. Omitting the - * conversation scope lists across conversations, token-scoped to the user. */ - async list(params?: ArtifactListParams) { - const query = new URLSearchParams(); - if (params?.name !== undefined && params.name !== "") query.set("name", params.name); - for (const type of params?.type ?? []) query.append("kind", type); - if (params?.cursor !== undefined) query.set("after", params.cursor); - query.set("limit", String(params?.limit ?? pageLimit)); - const res = await request(`/v1/artifacts?${query.toString()}`); - const envelope = (await res.json()) as CloudListEnvelope; - return { artifacts: envelope.data.map(toSummary), nextCursor: nextCursorOf(envelope) }; - }, - - /** GET /v1/artifacts/:id → the stored openui-lang program (bare program; - * the renderer's parser sniffs the `root = …` root). */ - async get(id: string): Promise { - const res = await request(`/v1/artifacts/${encodeURIComponent(id)}`); - const artifact = (await res.json()) as CloudArtifact; - return { ...toSummary(artifact), content: artifact.content }; - }, - - /** POST /v1/artifacts/:id {content}. Send the edited inner program (a - * string); omit version to let the server bump it. */ - async update(patch: { id: string; content: unknown }): Promise { - const content = - typeof patch.content === "string" ? patch.content : JSON.stringify(patch.content); - const res = await request(`/v1/artifacts/${encodeURIComponent(patch.id)}`, { - method: "POST", - body: JSON.stringify({ content }), - }); - return toSummary((await res.json()) as CloudArtifact); - }, - }; -} diff --git a/examples/openui-cloud/src/lib/thesys/frontendTokenManager.ts b/examples/openui-cloud/src/lib/thesys/frontendTokenManager.ts deleted file mode 100644 index 0f2055b54..000000000 --- a/examples/openui-cloud/src/lib/thesys/frontendTokenManager.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Frontend session-token (fct_) lifecycle for the browser plane. - * - * - The token rides ONLY the `x-thesys-frontend-token` header; `Authorization` - * on /v1/* always means the master key (server-side). - * - Minting happens on YOUR backend (here, the /api/frontend-token proxy), - * which calls the cloud mint endpoint with the master key and decides the - * end-user identity server-side. The browser sends no body and never names - * its own user. - * - Mint response: { token: 'fct_…', expires_at: }, TTL ~15 min. - * - * A fetch override (not static headers) is used so the token can refresh - * mid-session — the chat provider captures the storage object once at mount. - */ - -export const FRONTEND_TOKEN_HEADER = "x-thesys-frontend-token"; - -export interface MintFrontendTokenResponse { - token: string; - expires_at: number; // unix seconds -} - -export interface FrontendTokenManagerOptions { - /** Your backend mint endpoint, e.g. "/api/frontend-token". */ - mintUrl: string; - /** Override for tests / SSR. Defaults to globalThis.fetch. */ - fetch?: typeof fetch; - /** Refresh this many seconds before expiry. Default 60. */ - refreshSkewSeconds?: number; -} - -export interface FrontendTokenManager { - /** A token valid for at least refreshSkewSeconds (single-flight mint). */ - getToken(): Promise; - /** Drop the cached token. Pass the token that 401'd so a concurrent refresh - * is not discarded. */ - invalidate(staleToken?: string): void; -} - -export function createFrontendTokenManager({ - mintUrl, - fetch: customFetch, - refreshSkewSeconds = 60, -}: FrontendTokenManagerOptions): FrontendTokenManager { - const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis); - - let token: string | null = null; - let expiresAt = 0; // unix seconds - let inflight: Promise | null = null; - - const mint = async (): Promise => { - const res = await fetchImpl(mintUrl, { method: "POST" }); - if (!res.ok) { - throw new Error(`frontend-token mint failed: ${res.status} ${res.statusText}`); - } - const body = (await res.json()) as MintFrontendTokenResponse; - token = body.token; - expiresAt = body.expires_at; - return body.token; - }; - - return { - async getToken(): Promise { - const nowSeconds = Date.now() / 1000; - if (token !== null && nowSeconds < expiresAt - refreshSkewSeconds) return token; - // Single-flight: callers during a refresh await the same mint. - if (inflight === null) { - inflight = mint().finally(() => { - inflight = null; - }); - } - return inflight; - }, - - invalidate(staleToken?: string): void { - if (staleToken === undefined || staleToken === token) { - token = null; - expiresAt = 0; - } - }, - }; -} - -/** - * Wrap a base fetch so every request carries a fresh token, with one reactive - * retry on 401. The request is re-sent with the same init — pass re-readable - * (string) bodies only. - */ -export function createFctFetch(tokens: FrontendTokenManager, baseFetch?: typeof fetch): typeof fetch { - const fetchImpl = baseFetch ?? globalThis.fetch.bind(globalThis); - - return async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const token = await tokens.getToken(); - const headers = new Headers(init?.headers); - headers.set(FRONTEND_TOKEN_HEADER, token); - - let res = await fetchImpl(input, { ...init, headers }); - - if (res.status === 401) { - tokens.invalidate(token); - const freshToken = await tokens.getToken(); - const retryHeaders = new Headers(init?.headers); - retryHeaders.set(FRONTEND_TOKEN_HEADER, freshToken); - res = await fetchImpl(input, { ...init, headers: retryHeaders }); - } - - return res; - }; -} diff --git a/examples/openui-cloud/src/lib/thesys/index.ts b/examples/openui-cloud/src/lib/thesys/index.ts deleted file mode 100644 index f93d3e38e..000000000 --- a/examples/openui-cloud/src/lib/thesys/index.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { ChatStorage } from "@openuidev/react-headless"; -import { cloudArtifactStorage } from "./artifactStorage"; -import { cloudThreadStorage } from "./threadStorage"; -import { - createFctFetch, - createFrontendTokenManager, - type FrontendTokenManager, -} from "./frontendTokenManager"; - -export { cloudArtifactStorage, type CloudArtifactStorageOptions } from "./artifactStorage"; -export { cloudItemsToMessages } from "./items"; -export { cloudThreadStorage, deriveTitle, type CloudThreadStorageOptions } from "./threadStorage"; -export { - FRONTEND_TOKEN_HEADER, - createFctFetch, - createFrontendTokenManager, - type FrontendTokenManager, - type FrontendTokenManagerOptions, - type MintFrontendTokenResponse, -} from "./frontendTokenManager"; -export * from "./wire"; - -/** Which storage surfaces openuiCloud wires. */ -export interface OpenuiCloudFeatures { - /** Stored-artifact reads + edits. Default true. */ - artifact?: boolean; -} - -export interface OpenuiCloudOptions { - /** - * The OpenUI Cloud API origin. Defaults to "https://api.thesys.dev" (the - * storage layer appends `/v1/...`). Set this to e.g. "http://localhost:3102" - * to run against a local stack. The browser calls this directly with the - * fct_ token — there is no same-origin proxy in between. - */ - apiBaseUrl?: string; - /** - * Where the short-lived fct_ session token comes from — either a URL of your - * backend mint endpoint (POST → { token, expires_at }, cached + refreshed - * here) or a function returning a fresh token (you own caching). The token - * rides the `x-thesys-frontend-token` header on every /v1 call. The master - * key is minted server-side and never reaches the browser. - */ - token: string | (() => Promise); - /** Which storage surfaces to wire. Omit to enable all. */ - features?: OpenuiCloudFeatures; - /** fetch override (tests / SSR). Defaults to globalThis.fetch. */ - fetch?: typeof fetch; - /** Refresh the cached token this many seconds before expiry (URL form). Default 60. */ - refreshSkewSeconds?: number; -} - -/** - * One-call browser wiring for OpenUI Cloud: a `ChatStorage` backed by the /v1 - * API, authenticated per-request with an fct_ session token. Pass it straight - * to ``. - * - * This is the READ/EDIT plane (browser → /v1/* with the fct_ token). - * Generation is the separate ChatLLM plane (browser → your backend → - * /v1/embed/responses with the master key). - */ -/** OpenUI Cloud API origin used when `apiBaseUrl` is omitted. The storage - * layer appends `/v1/...` to it. */ -const DEFAULT_API_BASE_URL = "https://api.thesys.dev"; - -export function openuiCloud(options: OpenuiCloudOptions): ChatStorage { - const tokens = toTokenManager(options); - const fctFetch = createFctFetch(tokens, options.fetch); - const artifactOn = options.features?.artifact ?? true; - const baseUrl = options.apiBaseUrl ?? DEFAULT_API_BASE_URL; - - const storage: ChatStorage = { - thread: cloudThreadStorage({ baseUrl, fetch: fctFetch }), - }; - if (artifactOn) { - storage.artifact = cloudArtifactStorage({ baseUrl, fetch: fctFetch }); - } - return storage; -} - -/** Normalize the `token` option into a FrontendTokenManager. */ -function toTokenManager(options: OpenuiCloudOptions): FrontendTokenManager { - if (typeof options.token === "string") { - return createFrontendTokenManager({ - mintUrl: options.token, - fetch: options.fetch, - refreshSkewSeconds: options.refreshSkewSeconds, - }); - } - const provider = options.token; - let current: string | null = null; - return { - async getToken(): Promise { - current = await provider(); - return current; - }, - invalidate(staleToken?: string): void { - if (staleToken === undefined || staleToken === current) current = null; - }, - }; -} diff --git a/examples/openui-cloud/src/lib/thesys/items.ts b/examples/openui-cloud/src/lib/thesys/items.ts deleted file mode 100644 index 26c914d10..000000000 --- a/examples/openui-cloud/src/lib/thesys/items.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { openAIConversationMessageFormat, type Message } from "@openuidev/react-headless"; -import type { CloudConversationItem } from "./wire"; - -/** - * Convert /v1 conversation items to AG-UI Message[]. Each item is normalized - * into the OpenAI ConversationItem shape that openAIConversationMessageFormat - * .fromApi expects, then delegated — the grouping logic (function_call → - * assistant toolCalls, function_call_output → ToolMessage) stays in the SDK. - * - * Normalizations: - * - message content: assistant outputs arrive as part arrays; user inputs - * arrive as a plain string → wrap strings as a single text part. - * - function_call / function_call_output: a malformed row (missing the - * top-level call_id/name/output) is skipped so it can't crash fromApi. - * - other item types are skipped. - */ -function normalizeItem(item: CloudConversationItem): Record | null { - switch (item.type) { - case "message": { - const content = item.content; - const parts = Array.isArray(content) - ? content - : [ - { - type: item.role === "assistant" ? "output_text" : "input_text", - text: typeof content === "string" ? content : "", - }, - ]; - return { - id: item.id, - type: "message", - role: item.role ?? "user", - status: item.status ?? "completed", - content: parts, - }; - } - - case "function_call": { - if (typeof item.call_id !== "string" || typeof item.name !== "string") return null; - return { - id: item.id, - type: "function_call", - call_id: item.call_id, - name: item.name, - arguments: - typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}), - }; - } - - case "function_call_output": { - if (typeof item.call_id !== "string" || item.output === undefined) return null; - return { id: item.id, type: "function_call_output", call_id: item.call_id, output: item.output }; - } - - default: - return null; - } -} - -export function cloudItemsToMessages(items: CloudConversationItem[]): Message[] { - const normalized = items - .map(normalizeItem) - .filter((i): i is Record => i !== null); - return openAIConversationMessageFormat.fromApi(normalized); -} diff --git a/examples/openui-cloud/src/lib/thesys/threadStorage.ts b/examples/openui-cloud/src/lib/thesys/threadStorage.ts deleted file mode 100644 index a89af1664..000000000 --- a/examples/openui-cloud/src/lib/thesys/threadStorage.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Message, Thread, ThreadStorage, UserMessage } from "@openuidev/react-headless"; -import { cloudItemsToMessages } from "./items"; -import { - cloudRequest, - nextCursorOf, - type CloudConversation, - type CloudConversationItem, - type CloudListEnvelope, -} from "./wire"; - -export interface CloudThreadStorageOptions { - /** OpenUI Cloud API origin, e.g. "http://localhost:3102". */ - baseUrl: string; - /** The token-injecting fetch from createFctFetch. */ - fetch: typeof fetch; - /** Page size for list/items calls. */ - pageLimit?: number; -} - -/** Hard stop for the items pagination loop. */ -const MAX_ITEM_PAGES = 50; - -function toThread(conversation: CloudConversation): Thread { - return { - id: conversation.id, - title: conversation.title ?? "New conversation", - createdAt: conversation.created_at * 1000, // unix seconds → ms - }; -} - -/** Client-side title from the first user message (the API does not auto-title). */ -export function deriveTitle(firstMessage: UserMessage): string { - const content = firstMessage.content; - let text = ""; - if (typeof content === "string") { - text = content; - } else if (Array.isArray(content)) { - for (const part of content) { - if (part.type === "text" && typeof part.text === "string" && part.text.trim() !== "") { - text = part.text; - break; - } - } - } - text = text.trim(); - return (text === "" ? "New conversation" : text).slice(0, 60); -} - -export function cloudThreadStorage({ - baseUrl, - fetch: fetchImpl, - pageLimit = 100, -}: CloudThreadStorageOptions): ThreadStorage { - const request = cloudRequest(fetchImpl, baseUrl); - - return { - /** GET /v1/conversations?limit[&after]. Newest-first. */ - async listThreads(cursor?: string) { - const query = new URLSearchParams({ limit: String(pageLimit) }); - if (cursor !== undefined) query.set("after", cursor); - const res = await request(`/v1/conversations?${query.toString()}`); - const envelope = (await res.json()) as CloudListEnvelope; - return { threads: envelope.data.map(toThread), nextCursor: nextCursorOf(envelope) }; - }, - - /** POST /v1/conversations {title}. No messages and no user_id — the user is - * bound from the token; the first message arrives later on the generation - * plane (conversation linkage). */ - async createThread(firstMessage: UserMessage): Promise { - const res = await request(`/v1/conversations`, { - method: "POST", - body: JSON.stringify({ title: deriveTitle(firstMessage) }), - }); - return toThread((await res.json()) as CloudConversation); - }, - - /** GET /v1/conversations/:id/items?order=asc, paged, then mapped to Messages. */ - async getMessages(threadId: string): Promise { - const items: CloudConversationItem[] = []; - let after: string | undefined; - for (let page = 0; page < MAX_ITEM_PAGES; page++) { - const query = new URLSearchParams({ order: "asc", limit: String(pageLimit) }); - if (after !== undefined) query.set("after", after); - const res = await request( - `/v1/conversations/${encodeURIComponent(threadId)}/items?${query.toString()}`, - ); - const envelope = (await res.json()) as CloudListEnvelope; - items.push(...envelope.data); - after = nextCursorOf(envelope); - if (after === undefined) break; - } - return cloudItemsToMessages(items); - }, - - /** POST /v1/conversations/:id {title}. */ - async updateThread(thread: Thread): Promise { - const res = await request(`/v1/conversations/${encodeURIComponent(thread.id)}`, { - method: "POST", - body: JSON.stringify({ title: thread.title }), - }); - return toThread((await res.json()) as CloudConversation); - }, - - /** DELETE /v1/conversations/:id (soft delete). */ - async deleteThread(id: string): Promise { - await request(`/v1/conversations/${encodeURIComponent(id)}`, { method: "DELETE" }); - }, - }; -} diff --git a/examples/openui-cloud/src/lib/thesys/wire.ts b/examples/openui-cloud/src/lib/thesys/wire.ts deleted file mode 100644 index c008bac6a..000000000 --- a/examples/openui-cloud/src/lib/thesys/wire.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Wire types for the OpenUI Cloud /v1 API, plus the shared list envelope, - * cursor rule, and request helper. Field-for-field mirrors of the API DTOs. - */ - -/** A conversation. `created_at` is unix SECONDS. */ -export interface CloudConversation { - id: string; - object: "conversation"; - created_at: number; - title?: string; - metadata?: Record; - user_id?: string; - app_id?: string; -} - -/** A conversation item (full Responses item shape). */ -export interface CloudConversationItem { - id: string; - object: "conversation.item"; - type: string; // message | function_call | function_call_output | ... - role?: string; - status?: string; - content?: unknown; - metadata?: Record; - created_at: number; - call_id?: string; - name?: string; - arguments?: string; - output?: unknown; -} - -/** A stored artifact. `content` is the renderer-ready openui-lang program. */ -export interface CloudArtifact { - id: string; - object: "openui.artifact"; - conversation_id: string; - kind: string; // 'slides' | 'report' - name?: string; - version?: string; // server bumps via String(Date.now()) when omitted - content: string; - created_at: number; - updated_at?: number; -} - -/** List envelope shared by all paged endpoints. */ -export interface CloudListEnvelope { - object: "list"; - data: T[]; - has_more: boolean; - first_id?: string; - last_id?: string; -} - -/** Forward cursor: pass `last_id` back as `?after=` when there's another page. */ -export function nextCursorOf(envelope: CloudListEnvelope): string | undefined { - return envelope.has_more && envelope.last_id ? envelope.last_id : undefined; -} - -/** - * Request helper: prefix baseUrl, set JSON content-type only when sending a - * body, throw on non-2xx. `fetchImpl` is the token-injecting fetch — auth - * lives there, never here. - */ -export function cloudRequest(fetchImpl: typeof fetch, baseUrl: string) { - const base = baseUrl.replace(/\/+$/, ""); - return async (path: string, init?: RequestInit): Promise => { - const res = await fetchImpl(`${base}${path}`, { - ...init, - headers: { - ...(init?.body ? { "Content-Type": "application/json" } : {}), - ...init?.headers, - }, - }); - if (!res.ok) { - throw new Error( - `OpenUI Cloud: ${init?.method ?? "GET"} ${path} failed: ${res.status} ${res.statusText}`, - ); - } - return res; - }; -} diff --git a/packages/openui-cli/README.md b/packages/openui-cli/README.md index 0e213fe03..216a15f25 100644 --- a/packages/openui-cli/README.md +++ b/packages/openui-cli/README.md @@ -9,7 +9,9 @@ Command-line tools for starting OpenUI projects and generating model instruction It currently supports two workflows: -- scaffolding a new OpenUI Chat app +- scaffolding a new OpenUI app from one of two templates: + - **OpenUI Chat** — a Next.js app where you bring your own model key (OpenAI) + - **OpenUI Cloud** — a Next.js app backed by OpenUI Cloud for managed conversations, artifacts, and streaming - generating a system prompt or JSON Schema from a `createLibrary()` export ## Install @@ -24,12 +26,19 @@ bunx @openuidev/cli@latest --help ## Quick Start -Create a new chat app: +Create a new app (you'll be prompted to pick a template): ```bash npx @openuidev/cli@latest create ``` +Skip the prompt and pick a template directly: + +```bash +npx @openuidev/cli@latest create --template openui-chat +npx @openuidev/cli@latest create --template openui-cloud +``` + Generate a prompt from a library file: ```bash @@ -46,7 +55,7 @@ npx @openuidev/cli@latest generate ./src/library.ts --json-schema ### `openui create` -Scaffolds a new Next.js app with OpenUI Chat. +Scaffolds a new Next.js app from the **OpenUI Chat** or **OpenUI Cloud** template. ```bash openui create [options] @@ -55,26 +64,44 @@ openui create [options] Options: - `-n, --name `: Project name +- `-t, --template