Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ADOPTERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Frameworks that integrate with, support, or generate OpenUI interfaces.
- **Lynx** — Renders OpenUI Lang as cross-platform interfaces through the OpenUI renderer and component library in `@lynx-js/genui`.

- Status: `Official integration`
- [Documentation](https://lynxjs.org/next/api/genui/index.html)
- [Documentation](https://lynxjs.org/next/react/genui/openui.html) · [Example](https://github.com/thesysdev/openui/tree/main/examples/openui-lynx)

- **LangChain** — Generates and renders interactive OpenUI dashboards and reports in LangChain and LangGraph applications.

Expand Down
118 changes: 118 additions & 0 deletions docs/content/docs/openui-lang/examples/lynx.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
title: Lynx
description: build a full native ReactLynx chat app that streams and renders OpenUI Lang.
---

Lynx officially renders OpenUI Lang through `@lynx-js/genui/openui`. The full-stack example pairs
a native ReactLynx chat client with a small Next.js model route, giving you the same end-to-end
shape as the React Native example without maintaining a second copy of the component schema.

[View source on GitHub →](https://github.com/thesysdev/openui/tree/main/examples/openui-lynx)

## Architecture

```text
ReactLynx chat app Next.js backend
┌────────────────────────┐ ┌─────────────────────────┐
│ Native List + composer │── messages ─▶│ POST /api/chat │
│ Conversation history │ │ Lynx OpenUI prompt │
│ Cumulative stream │◀─ raw text ──│ Streaming model call │
│ <OpenUiRenderer /> │ │ Server-side API key │
└────────────────────────┘ └─────────────────────────┘
```

The backend builds the exact instruction for Lynx's component set:

```ts
import { buildOpenUiSystemPrompt } from "@lynx-js/genui/openui/prompt";

const systemPrompt = buildOpenUiSystemPrompt({
appendix: "Prefer compact native layouts that fit a phone screen.",
});
```

The app creates the matching renderer library once and keeps each assistant response mounted in a
non-recycling Lynx list item:

```tsx
const library = useMemo(() => createOpenUiLibrary(), []);

<OpenUiRenderer
response={message.content}
library={library}
isStreaming={message.status !== "complete"}
onAction={handleAction}
/>;
```

`response` is always the complete text received for that assistant turn, not only the newest
delta. `isStreaming` stays true until the transport finishes, allowing incomplete forward
references and statements to parse progressively.

## Native streaming

The client uses Lynx's response-body stream rather than browser-only APIs:

```ts
const response = await lynx.fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
lynxExtension: { useStreaming: true },
});

const reader = response.body.getReader();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
accumulated += TextCodecHelper.decode(value);
updateAssistantMessage(accumulated);
}
```

The checked-in implementation also handles web `Uint8Array` chunks, coalesces renderer updates to
animation frames, and retains the reader so the user can stop generation.

## Chat behavior

The example includes:

- native welcome starters and a Lynx composer
- real multi-turn user and assistant history
- per-message streaming, loading, stopped, and error states
- stop and clear controls
- automatic list following that yields when the user scrolls away from the bottom
- `@ToAssistant(...)` actions that start another turn
- an `@OpenUrl(...)` host boundary ready for the embedding app's navigation bridge

The backend validates messages, keeps provider credentials server-side, streams raw `text/plain`,
and enables CORS so the same bundle works in Rspeedy's web preview.

## Run the example

Use Node.js 22.12 or newer. From the repository root:

```bash
pnpm install
cp examples/openui-lynx/backend/env.example examples/openui-lynx/backend/.env.local
```

Add `OPENAI_API_KEY` to the new file, then run both processes:

```bash
pnpm --filter openui-lynx dev
```

The backend listens on port `3001`; Rspeedy serves the web preview on port `8080` and prints a QR
code for Lynx Explorer. Rspeedy automatically uses the development machine's LAN address for the
device API URL. Set `PUBLIC_OPENUI_API_URL` in `app/.env` when using a tunnel or remote backend.

Verify both packages with:

```bash
pnpm --filter openui-lynx check:all
pnpm --filter openui-lynx build:all
```

See the [official Lynx OpenUI guide](https://lynxjs.org/next/react/genui/openui.html) for custom
ReactLynx components, runtime tools, and the current host configuration requirements.
1 change: 1 addition & 0 deletions docs/content/docs/openui-lang/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"examples/dashboard",
"examples/shadcn-chat",
"examples/react-native",
"examples/lynx",
"examples/vercel-ai-chat",
"examples/langgraph-chat",
"examples/react-email",
Expand Down
3 changes: 3 additions & 0 deletions examples/openui-lynx/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**/.next/
**/dist/
**/.env.local
137 changes: 137 additions & 0 deletions examples/openui-lynx/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# OpenUI Lynx Chat

A full-stack chat application that streams model-generated
[OpenUI Lang](https://www.openui.com/docs/openui-lang/overview) into a native ReactLynx UI.
It follows the architecture of the React Native example while using Lynx's official built-in
OpenUI component library.

## Architecture

```text
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ ReactLynx chat app │ HTTP │ Next.js model backend │
│ │ POST │ │
│ • Native message list │──────▶│ • Validates chat history │
│ • Starters + composer │ │ • Builds Lynx OpenUI prompt │
│ • Streaming fetch reader │◀──────│ • Streams raw OpenUI Lang │
│ • <OpenUiRenderer /> │ text │ • Keeps API keys server-side │
│ • @ToAssistant round-trips │ │ • CORS for web preview │
└──────────────────────────────┘ └──────────────────────────────┘
:8080 :3001
```

The server generates its system instruction with
`buildOpenUiSystemPrompt()` and the client renders with `createOpenUiLibrary()`. Both come
from the same `@lynx-js/genui` version, so the model and renderer share one component contract.

## Run it

Prerequisites: Node.js 22.12+, pnpm, an OpenAI API key, and
[Lynx Explorer](https://lynxjs.org/guide/start/quick-start.html#preview-on-mobile) for a native
device preview.

From the repository root:

```bash
pnpm install
cp examples/openui-lynx/backend/env.example examples/openui-lynx/backend/.env.local
```

Add your API key to `backend/.env.local`, then start both processes:

```bash
pnpm --filter openui-lynx dev
```

- Backend status: <http://localhost:3001>
- Web preview: <http://localhost:8080/__web_preview?casename=main.web.bundle>
- Native preview: scan the QR code printed by Rspeedy in Lynx Explorer

You can also run the processes independently:

```bash
pnpm --filter openui-lynx dev:backend
pnpm --filter openui-lynx dev:app
```

## Device networking

Rspeedy automatically compiles the first non-internal LAN address into the development bundle,
for example `http://192.168.1.10:3001/api/chat`. This matters because `localhost` on a phone is
the phone, not the development machine.

Override auto-detection from `app/.env` when using a simulator, tunnel, or remote backend:

```bash
PUBLIC_OPENUI_API_URL=https://your-tunnel.example/api/chat
```

Or pass a shell-only override before starting Rspeedy:

```bash
OPENUI_API_URL=http://10.0.2.2:3001/api/chat pnpm --filter openui-lynx dev:app
```

Only variables prefixed with `PUBLIC_` are bundled into the app. Never put the provider API key
in the client environment.

## Project structure

```text
examples/openui-lynx/
├── package.json # Full-stack convenience scripts
├── backend/
│ ├── env.example # Provider configuration
│ └── src/app/
│ ├── page.tsx # Backend status page
│ └── api/chat/route.ts # Validated raw-text model stream
└── app/
├── lynx.config.ts # Web/native builds + LAN API URL
└── src/
├── App.tsx # Native chat shell and action handling
├── App.css # Chat and OpenUI renderer styles
├── config.ts # Backend URL selection
├── useStreamingChat.ts # Cross-platform Lynx stream reader
└── types.ts # View and API message types
```

## What the client demonstrates

- A virtualized, non-recycling Lynx message list so generated component state remains mounted
- A native Lynx composer, starter prompts, stop, and clear controls
- Cumulative raw OpenUI Lang passed to `<OpenUiRenderer />` while each response streams
- `@ToAssistant(...)` actions sent back as real user turns with the existing conversation history
- Safe fallback UI for network, provider, and renderer failures
- `@OpenUrl(...)` surfaced to the host so an embedding app can connect its own navigation bridge

The app requests standard response-body streaming from Lynx with
`lynxExtension: { useStreaming: true }`. Embedded Lynx hosts should enable standard Fetch API
streaming in their page configuration; current Lynx Explorer builds already provide the required
network service.

## Configure the model

`backend/.env.local` accepts:

```bash
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-5.5
# OPENAI_BASE_URL=https://api.openai.com/v1
```

`OPENAI_BASE_URL` makes the route usable with compatible gateways. The route deliberately returns
raw `text/plain` chunks rather than SSE because that maps directly to Lynx's streaming body reader.

## Verify production builds

```bash
pnpm --filter openui-lynx check:all
pnpm --filter openui-lynx build:all
```

## Learn more

- [Official Lynx OpenUI guide](https://lynxjs.org/next/react/genui/openui.html)
- [Lynx networking and streaming Fetch](https://lynxjs.org/guide/interaction/networking)
- [OpenUI Lang v0.5 specification](https://www.openui.com/docs/openui-lang/specification-v05)
- [React Native full-stack example](../openui-react-native)
3 changes: 3 additions & 0 deletions examples/openui-lynx/app/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Optional. Rspeedy auto-detects the development machine's LAN IP by default.
# Set this when using a tunnel, simulator alias, or remote backend.
PUBLIC_OPENUI_API_URL=http://192.168.1.10:3001/api/chat
62 changes: 62 additions & 0 deletions examples/openui-lynx/app/lynx.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { networkInterfaces } from "node:os";

import { pluginLynxConfig } from "@lynx-js/config-rsbuild-plugin";
import { pluginQRCode } from "@lynx-js/qrcode-rsbuild-plugin";
import { pluginReactLynx } from "@lynx-js/react-rsbuild-plugin";
import { defineConfig } from "@lynx-js/rspeedy";
import { pluginTypeCheck } from "@rsbuild/plugin-type-check";

function findLanAddress() {
for (const addresses of Object.values(networkInterfaces())) {
for (const address of addresses ?? []) {
if (address.family === "IPv4" && !address.internal) return address.address;
}
}

return "127.0.0.1";
}

const apiUrl = process.env.OPENUI_API_URL?.trim() || `http://${findLanAddress()}:3001/api/chat`;

export default defineConfig({
plugins: [
pluginQRCode(),
pluginReactLynx({
defaultDisplayLinear: false,
}),
pluginTypeCheck(),
pluginLynxConfig({
enableCSSInlineVariables: true,
}),
],
source: {
define: {
__DEFAULT_OPENUI_API_URL__: JSON.stringify(apiUrl),
},
entry: {
main: "./src/index.tsx",
},
},
environments: {
web: {
source: {
define: {
__IS_WEB__: true,
},
},
},
lynx: {
source: {
define: {
__IS_WEB__: false,
},
},
},
},
server: {
port: 8080,
},
output: {
filename: "[name].[platform].bundle",
},
});
33 changes: 33 additions & 0 deletions examples/openui-lynx/app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "openui-lynx-app",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "rspeedy build --environment lynx",
"build:all": "rspeedy build",
"build:web": "rspeedy build --environment web",
"dev": "rspeedy dev",
"preview": "rspeedy preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lynx-js/genui": "0.0.6",
"@lynx-js/lynx-ui": "3.135.2",
"@lynx-js/react": "0.122.1"
},
"devDependencies": {
"@lynx-js/config-rsbuild-plugin": "0.1.0",
"@lynx-js/qrcode-rsbuild-plugin": "0.5.0",
"@lynx-js/react-rsbuild-plugin": "0.17.2",
"@lynx-js/rspeedy": "0.15.2",
"@lynx-js/types": "4.0.0",
"@rsbuild/plugin-type-check": "1.3.4",
"@types/node": "catalog:",
"@types/react": "^18.3.28",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=22.12.0"
}
}
Loading
Loading