mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 12:28:17 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc4493f9af | ||
|
|
5b4fcaa349 | ||
|
|
2b3f3a723f |
@@ -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 />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ 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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -116,11 +118,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
@apply bg-background;
|
@apply bg-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-border rounded;
|
@apply bg-background rounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@@ -132,6 +134,39 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode: use page bg color for borders in chat message content */
|
||||||
|
.dark .docs-chat-content * {
|
||||||
|
border-color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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";
|
||||||
@@ -79,7 +80,10 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
<ThemeProvider>
|
||||||
|
{children}
|
||||||
|
<DocsChat />
|
||||||
|
</ThemeProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+334
-102
@@ -4,19 +4,124 @@ import { useRef, useEffect, useState } 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";
|
||||||
|
|
||||||
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" });
|
||||||
|
|
||||||
|
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\//, "/")
|
||||||
|
.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">
|
||||||
|
<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">
|
||||||
|
<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() {
|
export function DocsChat() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const restoredRef = useRef(false);
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
const { messages, sendMessage, status, setMessages } = useChat({ transport });
|
const { messages, sendMessage, status, setMessages, error } = useChat({
|
||||||
|
transport,
|
||||||
|
});
|
||||||
|
|
||||||
const isLoading = status === "streaming" || status === "submitted";
|
const isLoading = status === "streaming" || status === "submitted";
|
||||||
|
|
||||||
@@ -52,19 +157,48 @@ export function DocsChat() {
|
|||||||
}
|
}
|
||||||
}, [messages, isLoading]);
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
// Auto-open when new messages arrive
|
// Auto-open when new messages arrive (but not on initial restore)
|
||||||
const prevMessageCount = useRef(0);
|
const prevMessageCount = useRef<number | null>(null);
|
||||||
|
const initializedRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length > prevMessageCount.current) {
|
// Skip until after the first sessionStorage restore cycle
|
||||||
|
if (!initializedRef.current) {
|
||||||
|
// Wait one tick after mount to let restore settle
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
prevMessageCount.current = messages.length;
|
||||||
|
initializedRef.current = true;
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
prevMessageCount.current !== null &&
|
||||||
|
messages.length > prevMessageCount.current
|
||||||
|
) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
prevMessageCount.current = messages.length;
|
prevMessageCount.current = messages.length;
|
||||||
}, [messages.length]);
|
}, [messages.length]);
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change or error occurs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
}, [messages]);
|
}, [messages, error]);
|
||||||
|
|
||||||
|
// Cmd+K to focus prompt, Esc to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// Close message area when clicking outside
|
// Close message area when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -95,113 +229,211 @@ export function DocsChat() {
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTextFromParts = (
|
const hasVisibleContent = (
|
||||||
parts: (typeof messages)[number]["parts"],
|
parts: (typeof messages)[number]["parts"],
|
||||||
): string => {
|
): boolean => {
|
||||||
return parts
|
return parts.some(
|
||||||
.filter(
|
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
||||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
);
|
||||||
)
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-open when error occurs
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) setOpen(true);
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
|
const showMessages = open && (messages.length > 0 || !!error || isLoading);
|
||||||
|
const showSuggestions = focused && messages.length === 0 && !isLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
|
className={`mx-auto px-4 pb-4 [&>*]:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
|
||||||
>
|
>
|
||||||
{/* Messages panel */}
|
<div
|
||||||
{open && messages.length > 0 && (
|
className={`border rounded-lg overflow-hidden ${focused || showMessages ? "border-background" : "border-[var(--chat-bg)]"}`}
|
||||||
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
|
style={{ backgroundColor: "var(--chat-bg)" }}
|
||||||
<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
|
{/* Suggestions panel */}
|
||||||
ref={inputRef}
|
{showSuggestions && (
|
||||||
value={input}
|
<div>
|
||||||
onChange={(e) => {
|
<div className="flex items-center px-4 py-2 border-b border-background shrink-0">
|
||||||
setInput(e.target.value);
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
e.target.style.height = "auto";
|
json-render Docs
|
||||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
</span>
|
||||||
}}
|
</div>
|
||||||
placeholder="Ask about the docs..."
|
<div className="flex flex-wrap gap-2 p-3">
|
||||||
rows={1}
|
{SUGGESTIONS.map((s) => (
|
||||||
onFocus={() => {
|
<button
|
||||||
if (messages.length > 0) setOpen(true);
|
key={s}
|
||||||
}}
|
type="button"
|
||||||
onKeyDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.key === "Escape") {
|
e.preventDefault();
|
||||||
setOpen(false);
|
setOpen(true);
|
||||||
inputRef.current?.blur();
|
sendMessage({ text: s });
|
||||||
}
|
}}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
className="text-xs px-3 py-1.5 rounded-full border border-background bg-background font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||||
e.preventDefault();
|
>
|
||||||
handleSubmit(e);
|
{s}
|
||||||
}
|
</button>
|
||||||
}}
|
))}
|
||||||
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"
|
</div>
|
||||||
/>
|
</div>
|
||||||
<button
|
)}
|
||||||
type="submit"
|
{/* Messages panel */}
|
||||||
disabled={isLoading || !input.trim()}
|
{showMessages && (
|
||||||
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
|
<div className="max-h-[60vh] flex flex-col">
|
||||||
aria-label="Send message"
|
<div className="flex items-center justify-between px-4 py-2 border-b border-background 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"
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("a")) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
|
||||||
>
|
>
|
||||||
<svg
|
{!input && (
|
||||||
width="16"
|
<div className="absolute inset-0 flex items-center px-3 pointer-events-none">
|
||||||
height="16"
|
<span className="text-sm text-muted-foreground truncate flex-1">
|
||||||
viewBox="0 0 24 24"
|
Ask a question...
|
||||||
fill="none"
|
</span>
|
||||||
stroke="currentColor"
|
{!focused && !showMessages && (
|
||||||
strokeWidth="2"
|
<span className="text-muted-foreground/40 font-mono text-xs shrink-0">
|
||||||
strokeLinecap="round"
|
⌘K
|
||||||
strokeLinejoin="round"
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
if (messages.length > 0) setOpen(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
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 relative z-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !input.trim()}
|
||||||
|
className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`}
|
||||||
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
<line x1="12" y1="19" x2="12" y2="5" />
|
<svg
|
||||||
<polyline points="5 12 12 5 19 12" />
|
width="16"
|
||||||
</svg>
|
height="16"
|
||||||
</button>
|
viewBox="0 0 24 24"
|
||||||
</form>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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