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 }]);
});
});
17 changes: 11 additions & 6 deletions packages/react-ui/src/components/ChartsV2/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
# ChartsV2 Architecture
# ChartsV2 Architecture (Historical / Unimplemented Proposal)

> Auto-maintained by architect agent. Last updated: 2026-03-18 (rev 4)
> ⚠️ **This document describes a proposed architecture that was never implemented.**
>
> The `ChartsV2/` directory contains only this document — no code exists here.
> The current chart implementation lives in `packages/react-ui/src/components/Charts/` and remains Recharts-based.
> This document is retained as a historical record of the design exploration.
> Last updated: 2026-03-18 (rev 4)

## Overview

ChartsV2 is a D3-based charting system within OpenUI's React component library. It provides 7 chart types across 3 topologies (cartesian-scrollable, cartesian-condensed, and polar). D3 is used strictly for math (scales, path generation, stacking); React owns the DOM via JSX. The system replaces the original Recharts-based `Charts/` package.
ChartsV2 was a proposed D3-based charting system for OpenUI's React component library. It would have provided 7 chart types across 3 topologies (cartesian-scrollable, cartesian-condensed, and polar). D3 would be used strictly for math (scales, path generation, stacking); React would own the DOM via JSX. The system was intended to replace the original Recharts-based `Charts/` package but was not built.

The design follows a **hook-orchestrated architecture**: each chart delegates all shared state management (data, dimensions, hover, scroll, legend, tooltip) to a single "orchestrator" hook, then supplies only chart-specific rendering (series geometry, crosshairs, axis variant).

Expand Down Expand Up @@ -380,7 +385,7 @@ types/ --> utils/ (PaletteName only)
### Forbidden Imports

- No chart type imports another chart type
- No ChartsV2 file imports from the legacy `Charts/` directory
- No ChartsV2 file would import from the legacy `Charts/` directory
- No hook imports a component (except type-only imports for ReturnType inference)
- `hooks/core/` must not import from `hooks/cartesian/` or `hooks/polar/`

Expand Down Expand Up @@ -452,9 +457,9 @@ The ThemeProvider palette bypass bug is resolved -- scatter now routes through `

`paletteUtils.ts` defines all palette colors as hex strings rather than oklch/CSS custom properties. This means chart data colors do not participate in the ThemeProvider's oklch color system. The `useChartPalette` hook does check `theme[themePaletteName]` first (allowing theme-level override), so this is mitigated for users who provide custom palettes via ThemeProvider.

### 4. No Unit Tests (High Risk)
### 4. No Unit Tests (High Risk — if implemented)

The ChartsV2 directory has no test files. All validation is via Storybook visual testing only. The hooks contain significant logic (dimension calculation, scale construction, hover detection, scroll state) that would benefit from unit tests. The utility functions (`scrollUtils`, `mouseUtils`, `dataUtils`, `polarUtils`) are pure functions and trivially testable.
If ChartsV2 had been built, it would have had no test files as proposed. All validation was intended to be via Storybook visual testing only. The hooks contain significant logic (dimension calculation, scale construction, hover detection, scroll state) that would benefit from unit tests. The utility functions (`scrollUtils`, `mouseUtils`, `dataUtils`, `polarUtils`) are pure functions and trivially testable.

### 5. Missing Accessibility (Medium Risk)

Expand Down
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