mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a51cddf3b | ||
|
|
68ba7c6d7d | ||
|
|
7c4a6fed9a | ||
|
|
5b4fcaa349 | ||
|
|
2b3f3a723f |
@@ -1,8 +1,8 @@
|
|||||||
# json-render
|
# json-render
|
||||||
|
|
||||||
**The framework for User-Generated Interfaces (UGI).**
|
**The Generative UI framework.**
|
||||||
|
|
||||||
Dynamic, personalized UIs per user without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install @json-render/core @json-render/react
|
npm install @json-render/core @json-render/react
|
||||||
@@ -14,7 +14,7 @@ npm install @json-render/core @json-render/remotion
|
|||||||
|
|
||||||
## Why json-render?
|
## Why json-render?
|
||||||
|
|
||||||
json-render enables **User-Generated Interfaces**: dynamic UIs that end users create through natural language prompts, powered by Generative UI. You define the guardrails, AI generates within them:
|
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them:
|
||||||
|
|
||||||
- **Guardrailed** - AI can only use components in your catalog
|
- **Guardrailed** - AI can only use components in your catalog
|
||||||
- **Predictable** - JSON output matches your schema, every time
|
- **Predictable** - JSON output matches your schema, every time
|
||||||
@@ -285,7 +285,7 @@ flowchart LR
|
|||||||
```
|
```
|
||||||
|
|
||||||
1. **Define the guardrails** - what components, actions, and data bindings AI can use
|
1. **Define the guardrails** - what components, actions, and data bindings AI can use
|
||||||
2. **Users generate** - end users describe what they want in natural language
|
2. **Prompt** - describe what you want in natural language
|
||||||
3. **AI generates JSON** - output is always predictable, constrained to your catalog
|
3. **AI generates JSON** - output is always predictable, constrained to your catalog
|
||||||
4. **Render fast** - stream and render progressively as the model responds
|
4. **Render fast** - stream and render progressively as the model responds
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,7 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
|||||||
# Automatically populated when you add Vercel KV to your project
|
# Automatically populated when you add Vercel KV to your project
|
||||||
KV_REST_API_URL=
|
KV_REST_API_URL=
|
||||||
KV_REST_API_TOKEN=
|
KV_REST_API_TOKEN=
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
# RATE_LIMIT_PER_MINUTE=10
|
||||||
|
# RATE_LIMIT_PER_DAY=100
|
||||||
|
|||||||
@@ -121,4 +121,4 @@ AI can reference data paths in component props:
|
|||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
Learn about [actions](/docs/actions) for user interactions.
|
Learn about [action handlers](/docs/registry#action-handlers) for user interactions.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||||
import { DocsSidebar } from "@/components/docs-sidebar";
|
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||||
import { CopyPageButton } from "@/components/copy-page-button";
|
import { CopyPageButton } from "@/components/copy-page-button";
|
||||||
import { DocsChat } from "@/components/docs-chat";
|
|
||||||
|
|
||||||
export default function DocsLayout({
|
export default function DocsLayout({
|
||||||
children,
|
children,
|
||||||
@@ -25,7 +24,6 @@ export default function DocsLayout({
|
|||||||
<article>{children}</article>
|
<article>{children}</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DocsChat />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ export const metadata = { title: "Introduction" }
|
|||||||
|
|
||||||
# Introduction
|
# Introduction
|
||||||
|
|
||||||
The framework for User-Generated Interfaces (UGI). Dynamic, personalized UIs per user without sacrificing reliability.
|
The Generative UI framework. Generate dynamic, personalized UIs from prompts without sacrificing reliability.
|
||||||
|
|
||||||
## What is json-render?
|
## What is json-render?
|
||||||
|
|
||||||
json-render is the framework for **User-Generated Interfaces**: dynamic UIs that end users generate through natural language prompts, powered by Generative UI. You define the guardrails: what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
|
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails -- what components exist, what props they take, what actions are available. AI generates JSON that matches your schema, and your components render it natively on web or mobile.
|
||||||
|
|
||||||
Every interface is unique to the user, but every interface is safe and predictable.
|
Every generated interface is safe and predictable.
|
||||||
|
|
||||||
## Why json-render?
|
## Why json-render?
|
||||||
|
|
||||||
@@ -31,6 +31,6 @@ Render on web with React and on mobile with React Native from the same catalog a
|
|||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
1. Define the guardrails - what components, actions, and data bindings AI can use
|
1. Define the guardrails - what components, actions, and data bindings AI can use
|
||||||
2. Users generate - end users describe what they want in natural language
|
2. Prompt - describe what you want in natural language
|
||||||
3. AI generates JSON - output is always predictable, constrained to your catalog
|
3. AI generates JSON - output is always predictable, constrained to your catalog
|
||||||
4. Render fast - stream and render progressively as the model responds
|
4. Render fast - stream and render progressively as the model responds
|
||||||
|
|||||||
@@ -165,5 +165,5 @@ export default function Page() {
|
|||||||
|
|
||||||
- Learn about [catalogs](/docs/catalog) in depth
|
- Learn about [catalogs](/docs/catalog) in depth
|
||||||
- Explore [data binding](/docs/data-binding) for dynamic values
|
- Explore [data binding](/docs/data-binding) for dynamic values
|
||||||
- Add [actions](/docs/actions) for interactivity
|
- Add [action handlers](/docs/registry#action-handlers) for interactivity
|
||||||
- Implement [conditional visibility](/docs/visibility)
|
- Implement [conditional visibility](/docs/visibility)
|
||||||
|
|||||||
@@ -2,11 +2,19 @@ export const metadata = { title: "Registry" }
|
|||||||
|
|
||||||
# Registry
|
# Registry
|
||||||
|
|
||||||
Register React components and action handlers to bring your catalog to life.
|
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
|
||||||
|
|
||||||
## defineRegistry
|
What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
|
||||||
|
|
||||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
|
- **`@json-render/react`** — Components (React elements) and action handlers
|
||||||
|
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
|
||||||
|
- **`@json-render/remotion`** — Clip components, transitions, and effects
|
||||||
|
|
||||||
|
## @json-render/react
|
||||||
|
|
||||||
|
### defineRegistry
|
||||||
|
|
||||||
|
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { defineRegistry } from '@json-render/react';
|
import { defineRegistry } from '@json-render/react';
|
||||||
@@ -49,13 +57,13 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
|||||||
|
|
||||||
The returned object contains:
|
The returned object contains:
|
||||||
|
|
||||||
- `registry` - component registry for `<Renderer />`
|
- `registry` — component registry for `<Renderer />`
|
||||||
- `handlers` - factory for ActionProvider-compatible handlers
|
- `handlers` — factory for ActionProvider-compatible handlers
|
||||||
- `executeAction` - imperative action dispatch (for use outside the React tree)
|
- `executeAction` — imperative action dispatch (for use outside the React tree)
|
||||||
|
|
||||||
## Component Props
|
### Component Props
|
||||||
|
|
||||||
Each component in the registry receives a `ComponentContext` object:
|
Each component receives a `ComponentContext` object:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface ComponentContext {
|
interface ComponentContext {
|
||||||
@@ -68,13 +76,11 @@ interface ComponentContext {
|
|||||||
|
|
||||||
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||||
|
|
||||||
## Action Handlers
|
### Action Handlers
|
||||||
|
|
||||||
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
||||||
|
|
||||||
### Defining Actions
|
Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
|
||||||
|
|
||||||
Define available actions in your catalog:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
@@ -104,8 +110,6 @@ const catalog = defineCatalog(schema, {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Implementing Action Handlers
|
|
||||||
|
|
||||||
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
|
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -132,7 +136,7 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using Data Binding
|
### Data Binding
|
||||||
|
|
||||||
Use hooks inside your registry components to read and write data:
|
Use hooks inside your registry components to read and write data:
|
||||||
|
|
||||||
@@ -168,7 +172,7 @@ TextField: ({ props }) => {
|
|||||||
},
|
},
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using the Renderer
|
### Using the Renderer
|
||||||
|
|
||||||
Wire everything together with providers and the `<Renderer />` component:
|
Wire everything together with providers and the `<Renderer />` component:
|
||||||
|
|
||||||
@@ -205,6 +209,55 @@ function App({ spec, data, setState }) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## @json-render/react-native
|
||||||
|
|
||||||
|
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineRegistry } from '@json-render/react-native';
|
||||||
|
import { View, Text, Pressable } from 'react-native';
|
||||||
|
|
||||||
|
export const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: ({ props, children }) => (
|
||||||
|
<View style={styles.card}>
|
||||||
|
<Text style={styles.title}>{props.title}</Text>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
|
||||||
|
Button: ({ props, emit }) => (
|
||||||
|
<Pressable onPress={() => emit?.("press")}>
|
||||||
|
<Text>{props.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
|
||||||
|
|
||||||
|
## @json-render/remotion
|
||||||
|
|
||||||
|
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Renderer, standardComponents } from '@json-render/remotion';
|
||||||
|
|
||||||
|
// Use the standard components directly
|
||||||
|
<Renderer spec={timelineSpec} components={standardComponents} />
|
||||||
|
|
||||||
|
// Or extend with your own
|
||||||
|
const components = {
|
||||||
|
...standardComponents,
|
||||||
|
CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions.
|
||||||
|
|
||||||
|
See the [@json-render/remotion API reference](/docs/api/remotion) for the full API.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
Learn about [data binding](/docs/data-binding) for dynamic values.
|
Learn about [data binding](/docs/data-binding) for dynamic values.
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ export default function Home() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
||||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
|
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
|
||||||
The framework for User-Generated Interfaces
|
The Generative UI Framework
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||||
AI → json-render → UI
|
AI → json-render → UI
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||||
Dynamic, personalized UIs per user without sacrificing reliability.
|
Generate dynamic, personalized UIs from prompts without sacrificing
|
||||||
Predefined components and actions for safe, predictable output.
|
reliability. Predefined components and actions for safe, predictable
|
||||||
|
output.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Demo />
|
<Demo />
|
||||||
@@ -68,10 +69,10 @@ export default function Home() {
|
|||||||
<div className="text-xs text-muted-foreground font-mono mb-3">
|
<div className="text-xs text-muted-foreground font-mono mb-3">
|
||||||
02
|
02
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-semibold mb-2">Users Generate</h3>
|
<h3 className="text-lg font-semibold mb-2">AI Generates</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
End users describe what they want. AI generates JSON constrained
|
Describe what you want. AI generates JSON constrained to your
|
||||||
to your catalog. Every interface is unique to the user.
|
catalog. Every interface is unique.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -248,8 +249,8 @@ export default function Page() {
|
|||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||||
{[
|
{[
|
||||||
{
|
{
|
||||||
title: "User-Generated Interfaces",
|
title: "Generative UI",
|
||||||
desc: "Dynamic, personalized UIs per user powered by Generative UI",
|
desc: "Generate dynamic, personalized interfaces from prompts with AI",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Guardrails",
|
title: "Guardrails",
|
||||||
|
|||||||
@@ -18,15 +18,16 @@ GitHub repository: https://github.com/vercel-labs/json-render
|
|||||||
Documentation: https://json-render.dev/docs
|
Documentation: https://json-render.dev/docs
|
||||||
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
||||||
|
|
||||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
|
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||||
|
|
||||||
When answering questions:
|
When answering questions:
|
||||||
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
|
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
|
||||||
- Use the readFile tool to read specific documentation pages
|
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
|
||||||
- Always base your answers on the actual documentation content
|
- Always base your answers on the actual documentation content
|
||||||
- Be concise and accurate
|
- Be concise and accurate
|
||||||
- If the docs don't cover a topic, say so honestly
|
- If the docs don't cover a topic, say so honestly
|
||||||
- Do NOT include source references or file paths in your response`;
|
- Do NOT include source references or file paths in your response
|
||||||
|
- Do NOT use emojis in your responses`;
|
||||||
|
|
||||||
async function loadDocsFiles(): Promise<Record<string, string>> {
|
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||||
const files: Record<string, string> = {};
|
const files: Record<string, string> = {};
|
||||||
|
|||||||
+36
-13
@@ -28,6 +28,7 @@
|
|||||||
--border: oklch(0.85 0 0);
|
--border: oklch(0.85 0 0);
|
||||||
--input: oklch(0.85 0 0);
|
--input: oklch(0.85 0 0);
|
||||||
--ring: oklch(0.6 0 0);
|
--ring: oklch(0.6 0 0);
|
||||||
|
--chat-bg: oklch(0.95 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
--border: oklch(0.25 0 0);
|
--border: oklch(0.25 0 0);
|
||||||
--input: oklch(0.25 0 0);
|
--input: oklch(0.25 0 0);
|
||||||
--ring: oklch(0.4 0 0);
|
--ring: oklch(0.4 0 0);
|
||||||
|
--chat-bg: oklch(0.25 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -109,29 +111,49 @@
|
|||||||
@apply bg-transparent p-0;
|
@apply bg-transparent p-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
/* Hide page scrollbar */
|
||||||
::-webkit-scrollbar {
|
html {
|
||||||
width: 8px;
|
scrollbar-width: none;
|
||||||
height: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
html::-webkit-scrollbar {
|
||||||
@apply bg-background;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
@apply bg-border rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
@apply bg-muted-foreground;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tool call shimmer animation */
|
||||||
|
@keyframes tool-shimmer {
|
||||||
|
0% { opacity: 0.5; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
100% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-tool-shimmer {
|
||||||
|
animation: tool-shimmer 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix list rendering in chat content */
|
||||||
|
.docs-chat-content ul,
|
||||||
|
.docs-chat-content ol {
|
||||||
|
list-style-position: outside;
|
||||||
|
padding-left: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-chat-content li > p {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-chat-content li {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shiki dual theme support */
|
/* Shiki dual theme support */
|
||||||
.shiki,
|
.shiki,
|
||||||
.shiki span {
|
.shiki span {
|
||||||
@@ -144,3 +166,4 @@ button {
|
|||||||
color: var(--shiki-dark) !important;
|
color: var(--shiki-dark) !important;
|
||||||
background-color: var(--shiki-dark-bg) !important;
|
background-color: var(--shiki-dark-bg) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-11
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
|
|||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { DocsChat } from "@/components/docs-chat";
|
||||||
import { Analytics } from "@vercel/analytics/next";
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
src: "./fonts/GeistVF.woff",
|
src: "./fonts/GeistVF.woff",
|
||||||
@@ -22,13 +24,12 @@ export const metadata: Metadata = {
|
|||||||
template: "%s | json-render",
|
template: "%s | json-render",
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||||
keywords: [
|
keywords: [
|
||||||
"json-render",
|
"json-render",
|
||||||
"UGI",
|
|
||||||
"User-Generated Interfaces",
|
|
||||||
"AI UI generation",
|
|
||||||
"generative UI",
|
"generative UI",
|
||||||
|
"AI UI generation",
|
||||||
|
"user-generated interfaces",
|
||||||
"React components",
|
"React components",
|
||||||
"React Native",
|
"React Native",
|
||||||
"guardrails",
|
"guardrails",
|
||||||
@@ -42,23 +43,23 @@ export const metadata: Metadata = {
|
|||||||
locale: "en_US",
|
locale: "en_US",
|
||||||
url: "https://json-render.dev",
|
url: "https://json-render.dev",
|
||||||
siteName: "json-render",
|
siteName: "json-render",
|
||||||
title: "json-render | The framework for User-Generated Interfaces",
|
title: "json-render | The Generative UI Framework",
|
||||||
description:
|
description:
|
||||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/og",
|
url: "/og",
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
alt: "json-render - The framework for User-Generated Interfaces",
|
alt: "json-render - The Generative UI Framework",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
twitter: {
|
twitter: {
|
||||||
card: "summary_large_image",
|
card: "summary_large_image",
|
||||||
title: "json-render | The framework for User-Generated Interfaces",
|
title: "json-render | The Generative UI Framework",
|
||||||
description:
|
description:
|
||||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||||
images: ["/og"],
|
images: ["/og"],
|
||||||
creator: "@verabornnot",
|
creator: "@verabornnot",
|
||||||
},
|
},
|
||||||
@@ -71,15 +72,31 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
|
||||||
|
const chatWidth = Number(cookieStore.get("docs-chat-width")?.value) || 400;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{chatOpen && (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `@media(min-width:640px){body{padding-right:${chatWidth}px}}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</head>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
<ThemeProvider>
|
||||||
|
{children}
|
||||||
|
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||||
|
</ThemeProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+480
-146
@@ -1,24 +1,233 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useEffect, useState } from "react";
|
import {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
} from "react";
|
||||||
import { useChat } from "@ai-sdk/react";
|
import { useChat } from "@ai-sdk/react";
|
||||||
import { DefaultChatTransport } from "ai";
|
import { DefaultChatTransport } from "ai";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
|
||||||
const STORAGE_KEY = "docs-chat-messages";
|
const STORAGE_KEY = "docs-chat-messages";
|
||||||
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||||
|
|
||||||
export function DocsChat() {
|
const DESKTOP_DEFAULT_WIDTH = 400;
|
||||||
const [open, setOpen] = useState(false);
|
const DESKTOP_MIN_WIDTH = 300;
|
||||||
const [input, setInput] = useState("");
|
const DESKTOP_MAX_WIDTH = 700;
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const restoredRef = useRef(false);
|
|
||||||
|
|
||||||
const { messages, sendMessage, status, setMessages } = useChat({ transport });
|
function setCookie(name: string, value: string) {
|
||||||
|
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOL_LABELS: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; pastLabel: string; argKey?: string }
|
||||||
|
> = {
|
||||||
|
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
|
||||||
|
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function isToolPart(part: { type: string }): part is {
|
||||||
|
type: string;
|
||||||
|
toolCallId: string;
|
||||||
|
toolName?: string;
|
||||||
|
state: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
output?: unknown;
|
||||||
|
errorText?: string;
|
||||||
|
} {
|
||||||
|
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolName(part: { type: string; toolName?: string }): string {
|
||||||
|
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
|
||||||
|
return part.type.replace(/^tool-/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallDisplay({
|
||||||
|
part,
|
||||||
|
}: {
|
||||||
|
part: {
|
||||||
|
type: string;
|
||||||
|
toolCallId: string;
|
||||||
|
toolName?: string;
|
||||||
|
state: string;
|
||||||
|
input?: Record<string, unknown>;
|
||||||
|
output?: unknown;
|
||||||
|
errorText?: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const toolName = getToolName(part);
|
||||||
|
const config = TOOL_LABELS[toolName] ?? {
|
||||||
|
label: toolName,
|
||||||
|
pastLabel: toolName,
|
||||||
|
};
|
||||||
|
const isDone = part.state === "output-available";
|
||||||
|
const isError = part.state === "output-error";
|
||||||
|
const isRunning = !isDone && !isError;
|
||||||
|
const displayLabel = isRunning ? config.label : config.pastLabel;
|
||||||
|
|
||||||
|
const args = (part.input ?? {}) as Record<string, unknown>;
|
||||||
|
const argValue = config.argKey ? args[config.argKey] : undefined;
|
||||||
|
const argPreview =
|
||||||
|
argValue != null
|
||||||
|
? String(argValue)
|
||||||
|
.replace(/\/workspace\//g, "/")
|
||||||
|
.replace(/\.md$/, "")
|
||||||
|
.replace(/\/index$/, "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Link to the docs page if it's a /docs/ path from readFile
|
||||||
|
const docsLink =
|
||||||
|
toolName === "readFile" &&
|
||||||
|
(argPreview === "/docs" || argPreview.startsWith("/docs/"))
|
||||||
|
? argPreview
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const argEl = argPreview ? (
|
||||||
|
docsLink ? (
|
||||||
|
<Link href={docsLink} className="truncate underline underline-offset-2">
|
||||||
|
{argPreview}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate">{argPreview}</span>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-xs py-0.5 min-w-0">
|
||||||
|
{isRunning ? (
|
||||||
|
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
|
||||||
|
<span className="shrink-0">{displayLabel}</span>
|
||||||
|
{argEl}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
|
||||||
|
<span className="shrink-0">{displayLabel}</span>
|
||||||
|
{argEl}
|
||||||
|
{isError && <span className="text-destructive">failed</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
"What is json-render?",
|
||||||
|
"How do I install it?",
|
||||||
|
"How does streaming work?",
|
||||||
|
"What components are available?",
|
||||||
|
"How do I create a custom schema?",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DocsChat({
|
||||||
|
defaultOpen = false,
|
||||||
|
defaultWidth = DESKTOP_DEFAULT_WIDTH,
|
||||||
|
}: {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
defaultWidth?: number;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [isDesktop, setIsDesktop] = useState(false);
|
||||||
|
const [hasMounted, setHasMounted] = useState(false);
|
||||||
|
const [desktopWidth, setDesktopWidth] = useState(
|
||||||
|
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
|
||||||
|
);
|
||||||
|
const messagesScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
const isDraggingRef = useRef(false);
|
||||||
|
|
||||||
|
const { messages, sendMessage, status, setMessages, error } = useChat({
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
|
||||||
const isLoading = status === "streaming" || status === "submitted";
|
const isLoading = status === "streaming" || status === "submitted";
|
||||||
|
const showMessages = messages.length > 0 || !!error || isLoading;
|
||||||
|
|
||||||
|
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia("(min-width: 640px)");
|
||||||
|
setIsDesktop(mq.matches);
|
||||||
|
setHasMounted(true);
|
||||||
|
// If on mobile but sidebar was open from cookie, close it
|
||||||
|
if (!mq.matches && defaultOpen) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasMounted) {
|
||||||
|
setCookie("docs-chat-open", String(open));
|
||||||
|
}
|
||||||
|
}, [open, hasMounted]);
|
||||||
|
|
||||||
|
// Push page content on desktop when pane is open.
|
||||||
|
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
|
||||||
|
// instead of appearing right next to the sidebar's scrollbar.
|
||||||
|
useEffect(() => {
|
||||||
|
const body = document.body;
|
||||||
|
if (isDesktop && open) {
|
||||||
|
body.style.paddingRight = `${desktopWidth}px`;
|
||||||
|
if (!isDraggingRef.current) {
|
||||||
|
body.style.transition = "padding-right 150ms ease";
|
||||||
|
}
|
||||||
|
} else if (isDesktop) {
|
||||||
|
body.style.paddingRight = "0px";
|
||||||
|
body.style.transition = "padding-right 150ms ease";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
body.style.paddingRight = "0px";
|
||||||
|
body.style.transition = "";
|
||||||
|
};
|
||||||
|
}, [isDesktop, open, desktopWidth]);
|
||||||
|
|
||||||
|
// Resize handle drag
|
||||||
|
const handleResizePointerDown = useCallback(
|
||||||
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
isDraggingRef.current = true;
|
||||||
|
document.documentElement.style.transition = "none";
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startWidth = desktopWidth;
|
||||||
|
|
||||||
|
const onPointerMove = (ev: globalThis.PointerEvent) => {
|
||||||
|
const delta = startX - ev.clientX;
|
||||||
|
const newWidth = Math.min(
|
||||||
|
DESKTOP_MAX_WIDTH,
|
||||||
|
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
|
||||||
|
);
|
||||||
|
setDesktopWidth(newWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerUp = () => {
|
||||||
|
isDraggingRef.current = false;
|
||||||
|
document.documentElement.style.transition = "";
|
||||||
|
document.removeEventListener("pointermove", onPointerMove);
|
||||||
|
document.removeEventListener("pointerup", onPointerUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointermove", onPointerMove);
|
||||||
|
document.addEventListener("pointerup", onPointerUp);
|
||||||
|
},
|
||||||
|
[desktopWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Persist width to cookie
|
||||||
|
useEffect(() => {
|
||||||
|
setCookie("docs-chat-width", String(desktopWidth));
|
||||||
|
}, [desktopWidth]);
|
||||||
|
|
||||||
// Restore messages from sessionStorage on mount
|
// Restore messages from sessionStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,157 +261,282 @@ export function DocsChat() {
|
|||||||
}
|
}
|
||||||
}, [messages, isLoading]);
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
// Auto-open when new messages arrive
|
// Cmd+K to open sidebar and focus prompt, Escape to close
|
||||||
const prevMessageCount = useRef(0);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > prevMessageCount.current) {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
setOpen(true);
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
}
|
e.preventDefault();
|
||||||
prevMessageCount.current = messages.length;
|
setOpen((prev) => {
|
||||||
}, [messages.length]);
|
if (!prev) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 200);
|
||||||
// Scroll to bottom when messages change
|
}
|
||||||
useEffect(() => {
|
return !prev;
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
});
|
||||||
}, [messages]);
|
}
|
||||||
|
if (e.key === "Escape" && open && isDesktop) {
|
||||||
// Close message area when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
containerRef.current &&
|
|
||||||
!containerRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open, isDesktop]);
|
||||||
|
|
||||||
|
// Auto-focus input when opened
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
const timer = setTimeout(() => inputRef.current?.focus(), 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
// Auto-open when error occurs
|
||||||
e.preventDefault();
|
useEffect(() => {
|
||||||
if (!input.trim() || isLoading) return;
|
if (error) setOpen(true);
|
||||||
sendMessage({ text: input });
|
}, [error]);
|
||||||
setInput("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
// Scroll to bottom when messages change or error occurs
|
||||||
|
useEffect(() => {
|
||||||
|
const el = messagesScrollRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
});
|
||||||
|
}, [messages, error]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim() || isLoading) return;
|
||||||
|
sendMessage({ text: input });
|
||||||
|
setInput("");
|
||||||
|
},
|
||||||
|
[input, isLoading, sendMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
sessionStorage.removeItem(STORAGE_KEY);
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
setOpen(false);
|
}, [setMessages]);
|
||||||
inputRef.current?.focus();
|
|
||||||
|
const hasVisibleContent = (
|
||||||
|
parts: (typeof messages)[number]["parts"],
|
||||||
|
): boolean => {
|
||||||
|
return parts.some(
|
||||||
|
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTextFromParts = (
|
// Shared chat panel content used by both desktop and mobile
|
||||||
parts: (typeof messages)[number]["parts"],
|
const chatPanel = (
|
||||||
): string => {
|
<>
|
||||||
return parts
|
{/* Header */}
|
||||||
.filter(
|
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
|
||||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
<span className="text-sm font-medium">json-render Docs</span>
|
||||||
)
|
<div className="flex items-center gap-3">
|
||||||
.map((p) => p.text)
|
{showMessages && (
|
||||||
.join("");
|
<button
|
||||||
};
|
onClick={handleClear}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Clear conversation"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isDesktop && (
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Close panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content: suggestions or messages */}
|
||||||
|
{showMessages ? (
|
||||||
|
<div
|
||||||
|
ref={messagesScrollRef}
|
||||||
|
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{messages.map((message) => {
|
||||||
|
if (!hasVisibleContent(message.parts)) return null;
|
||||||
|
return (
|
||||||
|
<div key={message.id}>
|
||||||
|
{message.role === "user" ? (
|
||||||
|
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
|
{message.parts
|
||||||
|
.filter(
|
||||||
|
(p): p is Extract<typeof p, { type: "text" }> =>
|
||||||
|
p.type === "text",
|
||||||
|
)
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{message.parts.map((part, i) => {
|
||||||
|
if (part.type === "text" && part.text) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
|
||||||
|
>
|
||||||
|
<Streamdown>{part.text}</Streamdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isToolPart(part)) {
|
||||||
|
return (
|
||||||
|
<ToolCallDisplay key={part.toolCallId} part={part} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(error.message);
|
||||||
|
return parsed.message || parsed.error || error.message;
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
error.message || "Something went wrong. Please try again."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col">
|
||||||
|
<div className="flex flex-wrap gap-2 p-4">
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
sendMessage({ text: s });
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value);
|
||||||
|
e.target.style.height = "auto";
|
||||||
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
enterKeyHint="send"
|
||||||
|
placeholder="Ask a question..."
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !input.trim()}
|
||||||
|
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5" />
|
||||||
|
<polyline points="5 12 12 5 19 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
<>
|
||||||
<div
|
{/* Ask AI trigger button */}
|
||||||
ref={containerRef}
|
{!open && (
|
||||||
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
|
<button
|
||||||
>
|
onClick={() => setOpen(true)}
|
||||||
{/* Messages panel */}
|
className="fixed z-50 bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:translate-x-0 sm:right-4 flex items-center gap-2 px-4 py-2 rounded-lg border bg-background text-primary shadow-lg hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
||||||
{open && messages.length > 0 && (
|
aria-label="Ask AI"
|
||||||
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
json-render Docs
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleClear}
|
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
aria-label="Clear conversation"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 space-y-4 overflow-y-auto">
|
|
||||||
{messages.map((message) => {
|
|
||||||
const text = getTextFromParts(message.parts);
|
|
||||||
if (!text) return null;
|
|
||||||
return (
|
|
||||||
<div key={message.id}>
|
|
||||||
{message.role === "assistant" ? (
|
|
||||||
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
|
||||||
<Streamdown>{text}</Streamdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
|
||||||
{text}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Input bar */}
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onClick={() => inputRef.current?.focus()}
|
|
||||||
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
|
|
||||||
>
|
>
|
||||||
<textarea
|
Ask AI
|
||||||
ref={inputRef}
|
<kbd className="hidden sm:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
|
||||||
value={input}
|
<span>⌘</span>K
|
||||||
onChange={(e) => {
|
</kbd>
|
||||||
setInput(e.target.value);
|
</button>
|
||||||
e.target.style.height = "auto";
|
)}
|
||||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
||||||
}}
|
{/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
|
||||||
placeholder="Ask about the docs..."
|
<aside
|
||||||
rows={1}
|
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
|
||||||
onFocus={() => {
|
style={{ width: desktopWidth }}
|
||||||
if (messages.length > 0) setOpen(true);
|
aria-hidden={!open}
|
||||||
}}
|
>
|
||||||
onKeyDown={(e) => {
|
{/* Resize handle */}
|
||||||
if (e.key === "Escape") {
|
<div
|
||||||
setOpen(false);
|
onPointerDown={handleResizePointerDown}
|
||||||
inputRef.current?.blur();
|
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
|
||||||
}
|
/>
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
|
||||||
e.preventDefault();
|
</aside>
|
||||||
handleSubmit(e);
|
|
||||||
}
|
{/* Mobile: Sheet overlay/drawer — only after mount to avoid flash on desktop */}
|
||||||
}}
|
{hasMounted && !isDesktop && (
|
||||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
/>
|
<SheetContent
|
||||||
<button
|
side="right"
|
||||||
type="submit"
|
overlayClassName="!bg-background"
|
||||||
disabled={isLoading || !input.trim()}
|
className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
|
||||||
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
|
style={{ backgroundColor: "var(--background)", opacity: 1 }}
|
||||||
aria-label="Send message"
|
|
||||||
>
|
>
|
||||||
<svg
|
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
||||||
width="16"
|
{chatPanel}
|
||||||
height="16"
|
</SheetContent>
|
||||||
viewBox="0 0 24 24"
|
</Sheet>
|
||||||
fill="none"
|
)}
|
||||||
stroke="currentColor"
|
</>
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="19" x2="12" y2="5" />
|
|
||||||
<polyline points="5 12 12 5 19 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SheetPrimitive.Overlay
|
<SheetPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
|
"fixed inset-0 z-50 bg-black/75 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -27,17 +27,26 @@ const SheetOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const sheetSideVariants = {
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 max-w-xs data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||||
|
};
|
||||||
|
|
||||||
const SheetContent = React.forwardRef<
|
const SheetContent = React.forwardRef<
|
||||||
React.ComponentRef<typeof SheetPrimitive.Content>,
|
React.ComponentRef<typeof SheetPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||||
>(({ className, children, ...props }, ref) => (
|
side?: "left" | "right";
|
||||||
|
overlayClassName?: string;
|
||||||
|
}
|
||||||
|
>(({ className, children, side = "left", overlayClassName, ...props }, ref) => (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay className={overlayClassName} />
|
||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
|
||||||
"inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
|
sheetSideVariants[side],
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
export const PAGE_TITLES: Record<string, string> = {
|
export const PAGE_TITLES: Record<string, string> = {
|
||||||
// Home (no slug)
|
// Home (no slug)
|
||||||
"": "The framework for\nUser-Generated Interfaces",
|
"": "The Generative UI\nFramework",
|
||||||
|
|
||||||
// Top-level
|
// Top-level
|
||||||
playground: "Playground",
|
playground: "Playground",
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ const noopRateLimiter = {
|
|||||||
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 10 requests per minute (sliding window)
|
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
|
||||||
|
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
|
||||||
|
|
||||||
|
// Requests per minute (sliding window)
|
||||||
export const minuteRateLimit = {
|
export const minuteRateLimit = {
|
||||||
limit: async (identifier: string) => {
|
limit: async (identifier: string) => {
|
||||||
if (!_minuteRateLimit) {
|
if (!_minuteRateLimit) {
|
||||||
@@ -29,7 +32,7 @@ export const minuteRateLimit = {
|
|||||||
if (!redis) return noopRateLimiter.limit();
|
if (!redis) return noopRateLimiter.limit();
|
||||||
_minuteRateLimit = new Ratelimit({
|
_minuteRateLimit = new Ratelimit({
|
||||||
redis,
|
redis,
|
||||||
limiter: Ratelimit.slidingWindow(10, "1 m"),
|
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
|
||||||
prefix: "ratelimit:minute",
|
prefix: "ratelimit:minute",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -37,7 +40,7 @@ export const minuteRateLimit = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 100 requests per day (fixed window)
|
// Requests per day (fixed window)
|
||||||
export const dailyRateLimit = {
|
export const dailyRateLimit = {
|
||||||
limit: async (identifier: string) => {
|
limit: async (identifier: string) => {
|
||||||
if (!_dailyRateLimit) {
|
if (!_dailyRateLimit) {
|
||||||
@@ -45,7 +48,7 @@ export const dailyRateLimit = {
|
|||||||
if (!redis) return noopRateLimiter.limit();
|
if (!redis) return noopRateLimiter.limit();
|
||||||
_dailyRateLimit = new Ratelimit({
|
_dailyRateLimit = new Ratelimit({
|
||||||
redis,
|
redis,
|
||||||
limiter: Ratelimit.fixedWindow(100, "1 d"),
|
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
|
||||||
prefix: "ratelimit:daily",
|
prefix: "ratelimit:daily",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ const nextConfig = {
|
|||||||
destination: "/docs/registry",
|
destination: "/docs/registry",
|
||||||
permanent: true,
|
permanent: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: "/docs/actions",
|
||||||
|
destination: "/docs/registry#action-handlers",
|
||||||
|
permanent: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://turborepo.dev/schema.json",
|
"$schema": "https://turborepo.dev/schema.json",
|
||||||
"ui": "tui",
|
"ui": "tui",
|
||||||
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
|
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
|
|||||||
Reference in New Issue
Block a user