Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ node_modules
# Build output
dist
.next
.svelte-kit
*.tsbuildinfo
typings

Expand Down
1 change: 1 addition & 0 deletions examples/svelte-chat/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
OPENAI_API_KEY=your-openai-api-key-here
6 changes: 6 additions & 0 deletions examples/svelte-chat/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules
.svelte-kit
build
.env
.env.*
!.env.example
75 changes: 75 additions & 0 deletions examples/svelte-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# OpenUI Svelte Chat

A chat application built with [SvelteKit](https://svelte.dev/docs/kit), [Vercel AI SDK](https://ai-sdk.dev), and [`@openuidev/svelte-lang`](../../packages/svelte-lang/) — demonstrating how to render structured LLM output as live Svelte 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/svelte-lang` Renderer** parses the streaming openui-lang text and renders it as Svelte 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-...
```

### Run

```bash
pnpm --filter svelte-chat dev
```

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

## Project structure

```
src/
├── routes/
│ ├── +page.svelte # Chat UI with AI SDK Chat class + OpenUI Renderer
│ ├── +layout.svelte # Root layout (imports Tailwind)
│ ├── +layout.ts # Disables SSR (client-side rendering)
│ └── api/chat/+server.ts # AI SDK streaming endpoint
├── lib/
│ ├── library.ts # OpenUI component definitions (Stack, Card, TextContent, Button)
│ ├── tools.ts # AI tool definitions (weather, stocks, math, search)
│ └── components/ # Svelte component renderers
│ ├── Stack.svelte
│ ├── Card.svelte
│ ├── TextContent.svelte
│ └── Button.svelte
└── generated/
└── system-prompt.txt # LLM system prompt describing the openui-lang syntax
```

## Adding components

1. Create a Svelte component in `src/lib/components/`
2. Define it with `defineComponent()` in `src/lib/library.ts`
3. Add its signature to `src/generated/system-prompt.txt`

See the [`@openuidev/svelte-lang` README](../../packages/svelte-lang/README.md) for the full API.
28 changes: 28 additions & 0 deletions examples/svelte-chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"name": "svelte-chat",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@ai-sdk/openai": "^3.0.41",
"@ai-sdk/svelte": "^3.0.0",
"@openuidev/svelte-lang": "workspace:*",
"ai": "^6.0.116",
"chart.js": "^4.5.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4",
"svelte": "^5.0.0",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^6.0.0"
}
}
1 change: 1 addition & 0 deletions examples/svelte-chat/src/app.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "tailwindcss";
7 changes: 7 additions & 0 deletions examples/svelte-chat/src/app.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/// <reference types="@sveltejs/kit" />

declare global {
namespace App {}
}

export {};
12 changes: 12 additions & 0 deletions examples/svelte-chat/src/app.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenUI Svelte Chat</title>
%sveltekit.head%
</head>
<body>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
21 changes: 21 additions & 0 deletions examples/svelte-chat/src/lib/components/Button.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { getTriggerAction } from "@openuidev/svelte-lang";

let { props }: { props: { label?: string; action?: string }; renderNode: Snippet<[unknown]> } =
$props();

const triggerAction = getTriggerAction();
</script>

<button
class="inline-flex items-center rounded-lg border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-3 py-1.5 text-sm font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-colors cursor-pointer"
onclick={() =>
triggerAction(
props.label ?? "Click",
undefined,
props.action ? { type: props.action } : undefined,
)}
>
{props.label ?? "Button"}
</button>
19 changes: 19 additions & 0 deletions examples/svelte-chat/src/lib/components/Card.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from "svelte";

let {
props,
renderNode,
}: { props: { title?: string; children?: unknown }; renderNode: Snippet<[unknown]> } = $props();
</script>

<div class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 shadow-sm">
{#if props.title}
<h3 class="text-base font-semibold text-zinc-900 dark:text-zinc-100 mb-2">{props.title}</h3>
{/if}
{#if props.children}
<div class="space-y-2">
{@render renderNode(props.children)}
</div>
{/if}
</div>
128 changes: 128 additions & 0 deletions examples/svelte-chat/src/lib/components/Chart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<script lang="ts">
import type { Snippet } from "svelte";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarController,
LineController,
PieController,
DoughnutController,
ArcElement,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js";

ChartJS.register(
CategoryScale,
LinearScale,
BarController,
LineController,
PieController,
DoughnutController,
ArcElement,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Filler,
);

let {
props,
}: {
props: {
title?: string;
type?: string;
labels?: string[];
values?: number[];
datasetLabel?: string;
};
renderNode: Snippet<[unknown]>;
} = $props();

let canvas = $state<HTMLCanvasElement>();
let chartInstance: ChartJS | null = null;

const COLORS = [
"rgba(139, 92, 246, 0.7)",
"rgba(59, 130, 246, 0.7)",
"rgba(16, 185, 129, 0.7)",
"rgba(245, 158, 11, 0.7)",
"rgba(239, 68, 68, 0.7)",
"rgba(236, 72, 153, 0.7)",
"rgba(99, 102, 241, 0.7)",
"rgba(20, 184, 166, 0.7)",
];

const BORDER_COLORS = COLORS.map((c) => c.replace("0.7", "1"));

$effect(() => {
if (!canvas) return;

const labels = props.labels ?? [];
const values = props.values ?? [];
const chartType = (props.type ?? "bar") as "bar" | "line" | "pie" | "doughnut";
const isPieType = chartType === "pie" || chartType === "doughnut";

chartInstance?.destroy();

chartInstance = new ChartJS(canvas, {
type: chartType,
data: {
labels,
datasets: [
{
label: props.datasetLabel ?? props.title ?? "Data",
data: values,
backgroundColor: isPieType
? COLORS.slice(0, labels.length)
: COLORS[0],
borderColor: isPieType
? BORDER_COLORS.slice(0, labels.length)
: BORDER_COLORS[0],
borderWidth: isPieType ? 2 : 2,
borderRadius: chartType === "bar" ? 6 : undefined,
tension: chartType === "line" ? 0.3 : undefined,
fill: chartType === "line" ? "origin" : undefined,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: isPieType, position: "bottom" },
title: { display: false },
},
scales: isPieType
? {}
: {
y: { beginAtZero: true, grid: { color: "rgba(161,161,170,0.15)" } },
x: { grid: { display: false } },
},
},
});

return () => {
chartInstance?.destroy();
chartInstance = null;
};
});
</script>

<div class="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 p-4 shadow-sm">
{#if props.title}
<h3 class="text-base font-semibold text-zinc-900 dark:text-zinc-100 mb-3">{props.title}</h3>
{/if}
<div class="relative h-64">
<canvas bind:this={canvas}></canvas>
</div>
</div>
14 changes: 14 additions & 0 deletions examples/svelte-chat/src/lib/components/Stack.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from "svelte";

let {
props,
renderNode,
}: { props: { children?: unknown }; renderNode: Snippet<[unknown]> } = $props();
</script>

<div class="flex flex-col gap-4">
{#if props.children}
{@render renderNode(props.children)}
{/if}
</div>
7 changes: 7 additions & 0 deletions examples/svelte-chat/src/lib/components/TextContent.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from "svelte";

let { props }: { props: { text?: string }; renderNode: Snippet<[unknown]> } = $props();
</script>

<p class="text-sm leading-relaxed text-zinc-700 dark:text-zinc-300">{props.text ?? ""}</p>
Loading
Loading