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
94 changes: 94 additions & 0 deletions examples/vue-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# OpenUI Vue Chat

A chat application built with [Nuxt 3](https://nuxt.com), [Vercel AI SDK](https://ai-sdk.dev), and [`@openuidev/vue-lang`](../../packages/vue-lang/) — demonstrating how to render structured LLM output as live Vue components.

## How it works

1. **User sends a message** via the chat input
2. **Server streams a response** using the Vercel AI SDK with OpenAI, guided by a system prompt written in openui-lang syntax
3. **`@openuidev/vue-lang` Renderer** parses the streaming openui-lang text and renders it as Vue components in real time
4. **Tool calls** (weather, stocks, math, web search) are displayed inline with status indicators

## Setup

### Prerequisites

- Node.js 18+
- [pnpm](https://pnpm.io/)
- An OpenAI API key

### Install dependencies

From the monorepo root:

```bash
pnpm install
```

### Configure environment

```bash
cp .env.example .env
```

Edit `.env` and add your OpenAI API key:

```
OPENAI_API_KEY=sk-...
```

### Generate the system prompt (optional)

```bash
pnpm --filter vue-chat generate:prompt
```

The generated prompt lives at `generated/system-prompt.txt` and is checked in, so this step is only needed after modifying component definitions in `lib/library.ts`.

### Run

```bash
pnpm --filter vue-chat dev
```

Open [http://localhost:3000](http://localhost:3000).

## Project structure

```
app.vue # Root component (renders NuxtPage)
nuxt.config.ts # Nuxt config (ssr: false, Tailwind CSS, Nitro bundling)
pages/
└── index.vue # Chat UI with AI SDK Chat class + OpenUI Renderer
components/
├── ChatHeader.vue # Top bar with title
├── ChatInput.vue # Message input + send/stop buttons
├── UserMessage.vue # Renders user message parts
├── AssistantMessage.vue # Renders assistant message with OpenUI Renderer
├── LoadingIndicator.vue # Animated loading dots
├── WelcomeScreen.vue # Start page with example prompts
└── openui/ # Vue component renderers for openui-lang output
├── Stack.vue
├── Card.vue
├── TextContent.vue
├── Button.vue
└── Chart.vue
lib/
├── library.ts # OpenUI component definitions (Stack, Card, TextContent, Button, Chart)
└── tools.ts # AI tool definitions (weather, stocks, math, search)
server/
└── api/chat.post.ts # AI SDK streaming endpoint
generated/
└── system-prompt.txt # LLM system prompt describing the openui-lang syntax
assets/
└── app.css # Tailwind CSS entry point
```

## Adding components

1. Create a Vue component in `components/openui/`
2. Define it with `defineComponent()` in `lib/library.ts`
3. Run `pnpm --filter vue-chat generate:prompt` to update `generated/system-prompt.txt`
4. Commit the updated prompt file

See the [`@openuidev/vue-lang` README](../../packages/vue-lang/README.md) for the full API.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "The open standard for generative UI — a streaming-first language, React runtime, and component libraries for building AI-powered chat and copilot interfaces",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "pnpm -r test"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
Expand Down
120 changes: 120 additions & 0 deletions packages/react-lang/src/__tests__/library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { createLibrary, defineComponent } from "../library.js";

const DummyComponent = (() => null) as any;

function makeComponent(name: string, schema: z.ZodObject, description: string) {
return defineComponent({
name,
props: schema,
description,
component: DummyComponent,
});
}

describe("defineComponent", () => {
it("returns an object with name, props, description, component, and ref", () => {
const schema = z.object({ label: z.string() });
const result = defineComponent({
name: "Badge",
props: schema,
description: "A simple badge",
component: DummyComponent,
});

expect(result.name).toBe("Badge");
expect(result.props).toBe(schema);
expect(result.description).toBe("A simple badge");
expect(result.component).toBe(DummyComponent);
expect(result.ref).toBeDefined();
});

it("stores the component name for later registration by createLibrary", () => {
const schema = z.object({ title: z.string() });
const comp = defineComponent({
name: "Heading",
props: schema,
description: "A heading element",
component: DummyComponent,
});

expect(comp.name).toBe("Heading");
expect(comp.props).toBe(schema);
});
});

describe("createLibrary", () => {
const TextContent = makeComponent(
"TextContent",
z.object({ text: z.string() }),
"Displays text content",
);

const Container = makeComponent(
"Container",
z.object({ title: z.string() }),
"A container with a title",
);

it("creates a library with a components record", () => {
const lib = createLibrary({ components: [TextContent, Container] });

expect(lib.components.TextContent).toBe(TextContent);
expect(lib.components.Container).toBe(Container);
expect(Object.keys(lib.components)).toHaveLength(2);
});

it("stores root and componentGroups", () => {
const lib = createLibrary({
components: [TextContent],
root: "TextContent",
componentGroups: [{ name: "Display", components: ["TextContent"] }],
});

expect(lib.root).toBe("TextContent");
expect(lib.componentGroups).toEqual([{ name: "Display", components: ["TextContent"] }]);
});

it("throws if root component is not found in components", () => {
expect(() =>
createLibrary({
components: [TextContent],
root: "NonExistent",
}),
).toThrow(/Root component "NonExistent" was not found/);
});

it("prompt() returns a string containing component descriptions", () => {
const lib = createLibrary({
components: [TextContent, Container],
root: "TextContent",
});

const prompt = lib.prompt();
expect(typeof prompt).toBe("string");
expect(prompt.length).toBeGreaterThan(0);
expect(prompt).toContain("TextContent");
});

it("toJSONSchema() returns an object with $defs", () => {
const lib = createLibrary({
components: [TextContent],
root: "TextContent",
});

const schema = lib.toJSONSchema() as Record<string, unknown>;
expect(schema).toBeDefined();
expect(typeof schema).toBe("object");
expect(schema["$defs"]).toBeDefined();
expect(typeof schema["$defs"]).toBe("object");
});

it("works without a root component", () => {
const lib = createLibrary({ components: [TextContent] });

expect(lib.root).toBeUndefined();
expect(typeof lib.prompt()).toBe("string");
expect(lib.toJSONSchema()).toBeDefined();
});
});
10 changes: 6 additions & 4 deletions packages/react-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"README.md"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "vitest run",
"copy-css": "node cp-css.js",
"generate-scss-index": "node src/scripts/scss-import.js",
"generate:css-utils": "tsx src/scripts/generate-css-utils.ts",
Expand All @@ -80,7 +80,8 @@
"check:attw": "attw --pack . --ignore-rules no-resolution",
"prepare": "pnpm run build",
"prepublishOnly": "pnpm run check:publint && pnpm run check:attw",
"ci": "pnpm run lint:check && pnpm run format:check"
"ci": "pnpm run lint:check && pnpm run format:check",
"test:watch": "vitest"
},
"peerDependencies": {
"@openuidev/react-headless": "workspace:^",
Expand Down Expand Up @@ -159,7 +160,8 @@
"storybook": "^8.5.3",
"tailwindcss": "^3",
"tsx": "^4.19.2",
"vite": "^5.0.0"
"vite": "^5.0.0",
"vitest": "^4.0.18"
},
"keywords": [
"openui",
Expand Down Expand Up @@ -198,4 +200,4 @@
"plugin:storybook/recommended"
]
}
}
}
79 changes: 79 additions & 0 deletions packages/react-ui/src/__tests__/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { asArray, buildChartData, buildSliceData, hasAllProps } from "../genui-lib/helpers";

describe("hasAllProps", () => {
it("returns true when all keys are present", () => {
expect(hasAllProps({ a: 1, b: 2 }, "a", "b")).toBe(true);
});

it("returns false when a key is missing", () => {
expect(hasAllProps({ a: 1 }, "a", "b")).toBe(false);
});

it("returns false when a value is null", () => {
expect(hasAllProps({ a: null, b: 2 }, "a", "b")).toBe(false);
});
});

describe("asArray", () => {
it("returns the same array if already an array", () => {
expect(asArray([1, 2, 3])).toEqual([1, 2, 3]);
});

it("wraps a non-array value in an array", () => {
expect(asArray(42)).toEqual([42]);
});

it("returns an empty array for null/undefined", () => {
expect(asArray(null)).toEqual([]);
expect(asArray(undefined)).toEqual([]);
});
});

describe("buildChartData", () => {
it("builds chart data from tabular format", () => {
const result = buildChartData(["day", "views", "users"], [
["Mon", 100, 50],
["Tue", 200, 75],
]);
expect(result).toEqual([
{ category: "Mon", views: 100, users: 50 },
{ category: "Tue", views: 200, users: 75 },
]);
});

it("builds chart data from element format", () => {
const result = buildChartData(["Jan", "Feb"], [
{ type: "element", props: { category: "Sales", values: [100, 200] } },
]);
expect(result).toEqual([
{ category: "Jan", Sales: 100 },
{ category: "Feb", Sales: 200 },
]);
});

it("handles empty labels", () => {
expect(buildChartData([], [])).toEqual([]);
});
});

describe("buildSliceData", () => {
it("builds slice data from element nodes", () => {
const result = buildSliceData([
{ type: "element", props: { category: "A", value: 10 } },
{ type: "element", props: { category: "B", value: 20 } },
]);
expect(result).toEqual([
{ category: "A", value: 10 },
{ category: "B", value: 20 },
]);
});

it("filters out non-element nodes", () => {
const result = buildSliceData([
{ type: "element", props: { category: "A", value: 10 } },
{ type: "not-element", props: { category: "B", value: 20 } },
]);
expect(result).toEqual([{ category: "A", value: 10 }]);
});
});
7 changes: 7 additions & 0 deletions packages/react-ui/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";

export default defineConfig({
test: {
exclude: ["dist/**", "node_modules/**"],
},
});
Loading